forked from Mike/freno-dev
nook: add /agents, /changelog and /faq
Three organic surfaces, each answering an objection at the moment it bites. /agents lists all twelve agents with each hook's exact event surface (mirrors AgentSpec.all + hook/setup.go + the plugins) and what a card can do — allow/deny or observe. /changelog renders the appcast HTML the Sparkle updater already publishes, so release notes have one source of truth. /faq answers privacy (scoped honestly: no prompt or code content leaves the device), the notch question, battery, permissions, the macOS 14 floor and offline use — no refund question, per decision: the trial is the answer. Nav gains the three links. Tests: appcast parser suite (5 tests) covers CDATA, escaped HTML, Version-prefix titles and garbage input. Issues: FRE-12, FRE-13, FRE-14
This commit is contained in:
114
src/routes/api/the-nook/_appcast.ts
Normal file
114
src/routes/api/the-nook/_appcast.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
/**
|
||||
* Appcast reading for The Nook, shared by the download resolver and the
|
||||
* /changelog page. The same route-module tree-shake rule applies as in
|
||||
* `_download.ts`: helpers live in an underscore-prefixed module because
|
||||
* route files may only export HTTP handlers.
|
||||
*/
|
||||
|
||||
const APPCAST_KEY = "api/TheNook/appcast.xml";
|
||||
|
||||
/** Kept in step with the appcast route's own 5-minute cache. */
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface AppcastRelease {
|
||||
/** Human version, e.g. "1.2.0" — from sparkle:shortVersionString. */
|
||||
version: string;
|
||||
/** Build number from sparkle:version, when present. */
|
||||
build: string | null;
|
||||
/** Release date as published (already human-formatted in the appcast). */
|
||||
date: string | null;
|
||||
/** HTML release notes exactly as the Sparkle updater receives them. */
|
||||
notesHtml: string;
|
||||
}
|
||||
|
||||
function extract(tag: string, xml: string): string | null {
|
||||
const match = xml.match(
|
||||
new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, "i")
|
||||
);
|
||||
return match?.[1]?.trim() ?? null;
|
||||
}
|
||||
|
||||
function decodeCdata(value: string): string {
|
||||
const cdata = value.match(/^<!\[CDATA\[([\s\S]*)\]\]>$/);
|
||||
const inner = (cdata ? cdata[1] : value).trim();
|
||||
if (cdata) return inner;
|
||||
// Non-CDATA text is XML-escaped; descriptions arrive as escaped HTML.
|
||||
return inner
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
/** Strips the conventional "Version " prefix from an appcast item title. */
|
||||
function versionFromTitle(title: string): string {
|
||||
return title.replace(/^version\s+/i, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Sparkle appcast into release entries, newest first (the appcast's
|
||||
* own order). Empty or unparseable input yields an empty list — the page
|
||||
* renders its empty state rather than an error.
|
||||
*/
|
||||
export function parseAppcastItems(xml: string): AppcastRelease[] {
|
||||
const releases: AppcastRelease[] = [];
|
||||
for (const match of xml.matchAll(/<item\b[^>]*>([\s\S]*?)<\/item>/gi)) {
|
||||
const item = match[1];
|
||||
if (!item) continue;
|
||||
const short = decodeCdata(extract("sparkle:shortVersionString", item) ?? "");
|
||||
const title = versionFromTitle(decodeCdata(extract("title", item) ?? ""));
|
||||
const version = short || title;
|
||||
if (!version) continue;
|
||||
releases.push({
|
||||
version,
|
||||
build: decodeCdata(extract("sparkle:version", item) ?? "") || null,
|
||||
date: decodeCdata(extract("pubDate", item) ?? "") || null,
|
||||
notesHtml: decodeCdata(extract("description", item) ?? "")
|
||||
});
|
||||
}
|
||||
return releases;
|
||||
}
|
||||
|
||||
let cached: { xml: string; at: number } | null = null;
|
||||
|
||||
/** Drops the cached appcast so the next read hits S3 again. */
|
||||
export function invalidateAppcastCache(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/** Reads (and briefly caches) the raw appcast XML from the download bucket. */
|
||||
export async function fetchAppcastXml(): Promise<string | null> {
|
||||
if (cached && Date.now() - cached.at < TTL_MS) {
|
||||
return cached.xml;
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: env.AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||
}
|
||||
});
|
||||
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: env.VITE_DOWNLOAD_BUCKET_STRING,
|
||||
Key: APPCAST_KEY
|
||||
})
|
||||
);
|
||||
if (!response.Body) return null;
|
||||
|
||||
const xml = await response.Body.transformToString();
|
||||
cached = { xml, at: Date.now() };
|
||||
return xml;
|
||||
}
|
||||
|
||||
/** The releases behind /changelog, or an empty list when the appcast is unreadable. */
|
||||
export async function fetchAppcastReleases(): Promise<AppcastRelease[]> {
|
||||
const xml = await fetchAppcastXml();
|
||||
return xml ? parseAppcastItems(xml) : [];
|
||||
}
|
||||
64
src/routes/api/the-nook/appcast.test.ts
Normal file
64
src/routes/api/the-nook/appcast.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseAppcastItems } from "./_appcast";
|
||||
|
||||
// The /changelog page renders exactly what the Sparkle updater receives, so
|
||||
// the parser's contract is the appcast's contract: newest-first items, a
|
||||
// shortVersionString title with the raw <title> as fallback, CDATA and
|
||||
// plain-encoded descriptions both accepted, garbage in → empty list out.
|
||||
|
||||
const item = (over: Record<string, string>) => `
|
||||
<item>
|
||||
<title>Version ${over.title ?? "1.0.0"}</title>
|
||||
${over.short ? `<sparkle:shortVersionString>${over.short}</sparkle:shortVersionString>` : ""}
|
||||
${over.build ? `<sparkle:version>${over.build}</sparkle:version>` : ""}
|
||||
${over.date ? `<pubDate>${over.date}</pubDate>` : ""}
|
||||
${over.desc ?? `<description><![CDATA[<ul><li>Fixes</li></ul>]]></description>`}
|
||||
<enclosure url="https://example.com/Nook-1.0.0.dmg" type="application/x-apple-diskimage"/>
|
||||
</item>`;
|
||||
|
||||
describe("parseAppcastItems", () => {
|
||||
it("parses newest-first items with version, build, date and CDATA notes", () => {
|
||||
const xml = `<rss><channel>
|
||||
${item({ title: "1.2.0", short: "1.2.0", build: "42", date: "Sat, 03 Oct 2026 12:00:00 +0000" })}
|
||||
${item({ title: "1.1.0", short: "1.1.0", build: "41", date: "Fri, 02 Oct 2026 12:00:00 +0000" })}
|
||||
</channel></rss>`;
|
||||
|
||||
const releases = parseAppcastItems(xml);
|
||||
expect(releases.length).toBe(2);
|
||||
expect(releases[0].version).toBe("1.2.0");
|
||||
expect(releases[0].build).toBe("42");
|
||||
expect(releases[0].date).toContain("03 Oct 2026");
|
||||
expect(releases[0].notesHtml).toBe("<ul><li>Fixes</li></ul>");
|
||||
expect(releases[1].version).toBe("1.1.0");
|
||||
});
|
||||
|
||||
it("falls back to the plain title when shortVersionString is absent", () => {
|
||||
const xml = `<rss><channel>${item({ title: "1.0.0" })}</channel></rss>`;
|
||||
const releases = parseAppcastItems(xml);
|
||||
expect(releases[0].version).toBe("1.0.0");
|
||||
expect(releases[0].build).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts non-CDATA escaped-HTML descriptions", () => {
|
||||
const xml = `<rss><channel>
|
||||
${item({ desc: "<description><p>Hello</p></description>" })}
|
||||
</channel></rss>`;
|
||||
expect(parseAppcastItems(xml)[0].notesHtml).toBe("<p>Hello</p>");
|
||||
});
|
||||
|
||||
it("returns [] for empty, non-XML, or item-less input", () => {
|
||||
expect(parseAppcastItems("")).toEqual([]);
|
||||
expect(parseAppcastItems("<html>not an appcast</html>")).toEqual([]);
|
||||
expect(parseAppcastItems("<rss><channel></channel></rss>")).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips items with no version signal at all", () => {
|
||||
const xml = `<rss><channel>
|
||||
<item><description><![CDATA[<p>orphan</p>]]></description></item>
|
||||
${item({ title: "1.0.0" })}
|
||||
</channel></rss>`;
|
||||
const releases = parseAppcastItems(xml);
|
||||
expect(releases.length).toBe(1);
|
||||
expect(releases[0].version).toBe("1.0.0");
|
||||
});
|
||||
});
|
||||
216
src/routes/nook/agents.tsx
Normal file
216
src/routes/nook/agents.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* `/agents` on the Nook subdomain — supported agents and their event coverage.
|
||||
*
|
||||
* The registry below mirrors `AgentSpec.all` in the app
|
||||
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's
|
||||
* installed hook surface (hook/setup.go) or plugin event subscription
|
||||
* (plugins/*), normalized onto the shared event vocabulary
|
||||
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
||||
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
||||
* claims.
|
||||
*/
|
||||
import { For } from "solid-js";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
|
||||
interface AgentEntry {
|
||||
name: string;
|
||||
/** How The Nook connects to this agent. */
|
||||
wiring: string;
|
||||
/** Events the installed hook/plugin reports, on the shared vocabulary. */
|
||||
events: string[];
|
||||
/** What the agent can be asked to do from a Nook card. */
|
||||
gating: string;
|
||||
}
|
||||
|
||||
const AGENTS: AgentEntry[] = [
|
||||
{
|
||||
name: "Claude Code",
|
||||
wiring: "Hook entries in settings.json pointing at the shared hook binary",
|
||||
events: [
|
||||
"UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "StopFailure",
|
||||
"SubagentStart", "SubagentStop", "Notification", "PreToolUse",
|
||||
"PermissionRequest", "PostToolUse", "PostToolUseFailure",
|
||||
"PermissionDenied", "PreCompact"
|
||||
],
|
||||
gating: "Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||
},
|
||||
{
|
||||
name: "Codex",
|
||||
wiring: "Hook entries in hooks.json (+ hooks enabled in config.toml)",
|
||||
events: ["SessionStart", "UserPromptSubmit", "PermissionRequest", "Stop"],
|
||||
gating: "Full — permission prompts can be allowed or denied from a card"
|
||||
},
|
||||
{
|
||||
name: "OpenCode",
|
||||
wiring: "In-process plugin (~/.config/opencode/plugins/nook.js) on the server bus",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "MessageUpdate", "PermissionRequest", "QuestionAsked"
|
||||
],
|
||||
gating: "Full — the plugin answers OpenCode's own permission API when you tap Allow or Deny"
|
||||
},
|
||||
{
|
||||
name: "Pi",
|
||||
wiring: "In-process extension (~/.pi/agent/extensions/nook.ts)",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "MessageUpdate", "QuestionAsked", "QuestionResolved"
|
||||
],
|
||||
gating: "Full when you force verification — Pi alone never asks natively, so the gate engages on the approval tool"
|
||||
},
|
||||
{
|
||||
name: "Oh My Pi",
|
||||
wiring: "In-process extension package (~/.omp/agent/extensions/nook/)",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "MessageUpdate", "QuestionAsked", "QuestionResolved"
|
||||
],
|
||||
gating: "Full — honors the agent's own approval tiers, per-tool policies included"
|
||||
},
|
||||
{
|
||||
name: "Hermes",
|
||||
wiring: "Shell-hook entries in the agent's config, consent-gated by allowlist",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "Stop", "PreToolUse",
|
||||
"PostToolUse", "SubagentStart", "SubagentStop"
|
||||
],
|
||||
gating: "Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "OpenClaw",
|
||||
wiring: "Hook handler package (~/.openclaw/hooks/nook/)",
|
||||
events: ["SessionStart", "UserPromptSubmit", "AgentResponse", "PreCompact", "SessionEnd"],
|
||||
gating: "Observation — strict fire-and-forget; the agent surface has no tool or permission events"
|
||||
},
|
||||
{
|
||||
name: "Z.ai (ZCode)",
|
||||
wiring: "Hook entries in ~/.zcode/cli/config.json",
|
||||
events: [
|
||||
"SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest",
|
||||
"PostToolUse", "PostToolUseFailure", "Stop"
|
||||
],
|
||||
gating: "Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||
},
|
||||
{
|
||||
name: "Gemini CLI",
|
||||
wiring: "Hook entries in settings.json",
|
||||
events: ["SessionStart", "SessionEnd", "AgentStart", "Stop", "Notification"],
|
||||
gating: "Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "Cline",
|
||||
wiring: "One hook file per event in ~/Documents/Cline/Hooks",
|
||||
events: [
|
||||
"SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse",
|
||||
"PostToolUse", "Stop", "PreCompact", "Notification"
|
||||
],
|
||||
gating: "Observation — every event appears as a card; the agent keeps its own prompting"
|
||||
},
|
||||
{
|
||||
name: "Qwen Code",
|
||||
wiring: "Hook entries in settings.json (same surface as the Claude-style schema)",
|
||||
events: [
|
||||
"UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "StopFailure",
|
||||
"SubagentStart", "SubagentStop", "Notification", "PreToolUse",
|
||||
"PermissionRequest", "PostToolUse", "PostToolUseFailure",
|
||||
"PermissionDenied", "PreCompact"
|
||||
],
|
||||
gating: "Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
wiring: "Standalone hooks.json at ~/.dsh",
|
||||
events: [
|
||||
"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop",
|
||||
"SubagentStart", "SubagentStop"
|
||||
],
|
||||
gating: "Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
||||
}
|
||||
];
|
||||
|
||||
const GATING_BADGE: Record<string, { label: string; class: string }> = {
|
||||
full: {
|
||||
label: "Allow / Deny from a card",
|
||||
class: "text-green border-green/40 bg-green/10"
|
||||
},
|
||||
observation: {
|
||||
label: "Observe",
|
||||
class: "text-blue border-blue/40 bg-blue/10"
|
||||
}
|
||||
};
|
||||
|
||||
function gatingKey(entry: AgentEntry): "full" | "observation" {
|
||||
return entry.gating.startsWith("Full") ? "full" : "observation";
|
||||
}
|
||||
|
||||
export default function NookAgentsPage() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Supported agents"
|
||||
description="Every coding agent The Nook supports — all twelve, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||
ogTitle="The 12 agents The Nook supports, and what each hook sees"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Supported agents
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
||||
The Nook watches twelve coding agents in one place — your menu bar and
|
||||
notch show every session that is running, thinking, or blocked, and a
|
||||
card answers permission prompts without leaving your keyboard.
|
||||
</p>
|
||||
<p class="text-subtext1 mb-10 max-w-2xl text-sm">
|
||||
Each agent reports its own set of events. "Allow / Deny from a card"
|
||||
means the agent hands the decision to The Nook while it waits;
|
||||
"Observe" means every event still appears live, but the agent keeps
|
||||
its own prompting. Agents can run on this Mac or on remote machines
|
||||
over SSH — remote sessions report into the same grid.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<For each={AGENTS}>
|
||||
{(agent) => (
|
||||
<article
|
||||
class="border-overlay0 bg-surface0/40 rounded-2xl border p-5"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 class="text-text text-lg font-semibold">{agent.name}</h2>
|
||||
<span
|
||||
class={`shrink-0 rounded-full border px-3 py-1 text-xs font-semibold ${GATING_BADGE[gatingKey(agent)].class}`}
|
||||
>
|
||||
{GATING_BADGE[gatingKey(agent)].label}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-subtext0 mb-3 text-sm">{agent.wiring}</p>
|
||||
<p class="text-subtext1 mb-1 text-xs font-semibold tracking-wide uppercase">
|
||||
Events reported
|
||||
</p>
|
||||
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||
<For each={agent.events}>
|
||||
{(event) => (
|
||||
<code class="border-overlay0 bg-base text-subtext0 rounded-md border px-2 py-0.5 font-mono text-xs">
|
||||
{event}
|
||||
</code>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p class="text-subtext0 text-sm">{agent.gating}</p>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<p class="text-subtext1 mt-10 max-w-2xl text-sm">
|
||||
Running something else? A custom agent can wire itself in with the
|
||||
same one-line contract the built-in integrations use — see the
|
||||
plugin spec in the app's docs.
|
||||
</p>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
src/routes/nook/changelog.tsx
Normal file
81
src/routes/nook/changelog.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* `/changelog` on the Nook subdomain — rendered from the Sparkle appcast.
|
||||
*
|
||||
* The appcast already ships HTML release notes to the updater on every
|
||||
* launch, so this page renders exactly what Sparkle publishes: one source
|
||||
* of truth, no second place to write release notes. Doubles as the raw
|
||||
* material for the release-announcement job (marketing/README.md).
|
||||
*/
|
||||
import { For, Show } from "solid-js";
|
||||
import { query, createAsync } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { fetchAppcastReleases } from "~/routes/api/the-nook/_appcast";
|
||||
|
||||
const getReleases = query(async () => {
|
||||
"use server";
|
||||
return fetchAppcastReleases();
|
||||
}, "nook-changelog-releases");
|
||||
|
||||
export default function NookChangelogPage() {
|
||||
const releases = createAsync(() => getReleases());
|
||||
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Changelog"
|
||||
description="Every release of The Nook, newest first — agent orchestration, fan and thermal control for macOS."
|
||||
ogTitle="The Nook changelog"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Changelog
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-10 max-w-2xl text-lg">
|
||||
What changed in each release. The app updates through Sparkle —
|
||||
every entry here is what the updater shows.
|
||||
</p>
|
||||
|
||||
<Show
|
||||
when={(releases()?.length ?? 0) > 0}
|
||||
fallback={
|
||||
<p class="text-subtext0 text-sm">
|
||||
No releases published yet — check back soon.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="border-overlay0 space-y-10 border-l pl-6">
|
||||
<For each={releases()}>
|
||||
{(release) => (
|
||||
<article>
|
||||
<div class="mb-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 class="text-text text-xl font-semibold">
|
||||
Version {release.version}
|
||||
</h2>
|
||||
<Show when={release.build}>
|
||||
<span class="text-subtext1 font-mono text-xs">
|
||||
build {release.build}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={release.date}>
|
||||
<span class="text-subtext1 text-xs">{release.date}</span>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Notes are the publisher's own HTML, written to the same
|
||||
S3 appcast the Sparkle updater consumes. */}
|
||||
<div
|
||||
class="text-subtext0 text-sm [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-text [&_li]:ml-5 [&_li]:list-disc [&_p]:mb-2 [&_ul]:mb-2"
|
||||
innerHTML={release.notesHtml}
|
||||
/>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
src/routes/nook/faq.tsx
Normal file
106
src/routes/nook/faq.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* `/faq` on the Nook subdomain — answers to the objections that actually
|
||||
* stop a purchase. Privacy scoping follows marketing/positioning.md rule 3:
|
||||
* "no prompt or code content leaves the device" (telemetry and the trial
|
||||
* email do leave — never claim "no data leaves the device").
|
||||
*/
|
||||
import { For } from "solid-js";
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
|
||||
interface FaqEntry {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
const FAQ: FaqEntry[] = [
|
||||
{
|
||||
question: "Does The Nook read my prompts or my code?",
|
||||
answer:
|
||||
"No. The hooks and plugins that connect your agents to The Nook send session metadata only — which agent is running, which session, which event happened, which terminal it lives in. No prompt text and no code content leaves your device. Two things do leave the device, and we'd rather say so plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start. That's the whole list."
|
||||
},
|
||||
{
|
||||
question: "Do I need a Mac with a notch?",
|
||||
answer:
|
||||
"No. On a notched MacBook the island sits in the notch. On machines and displays without one, it anchors to the display your cursor is on, so a desktop setup with external monitors gets the same treatment. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
||||
},
|
||||
{
|
||||
question: "How much battery does it cost?",
|
||||
answer:
|
||||
"The Nook is a small native UI, not a background agent — it watches the agents you're already running; it doesn't run anything itself. The heaviest thing it does is read and write your Mac's fan and charging controllers, which is a lightweight sensor read. If you're running enough coding agents to need it, those agents dwarf the app's own draw — which is rather the point: the fan control exists because the agents are what heat the machine."
|
||||
},
|
||||
{
|
||||
question: "What permissions does it need?",
|
||||
answer:
|
||||
"The app itself runs unprivileged. Fan control and charge limiting go through a small helper component that macOS requires to run as root for hardware access — the app installs it on first use and you approve it once with your password. The hooks live in each agent's own config directory; you can inspect or remove every file it writes."
|
||||
},
|
||||
{
|
||||
question: "Why macOS 14 or later?",
|
||||
answer:
|
||||
"The interface is built on Apple's current observation framework, which ships with macOS 14 (Sonoma). Supporting older versions would mean rewriting the whole UI layer on a deprecated foundation — time that goes into the app instead. macOS 14 runs on hardware from 2018 onward, which covers the machines that run coding agents comfortably."
|
||||
},
|
||||
{
|
||||
question: "Does it work offline?",
|
||||
answer:
|
||||
"Yes. Everything The Nook does — the agent grid, permission cards, fan and thermal control — works with no network at all, and your agents' hooks talk to the app over a local socket. The network is only used for update checks, license activation, and the optional trial email."
|
||||
}
|
||||
];
|
||||
|
||||
export default function NookFaqPage() {
|
||||
const [open, setOpen] = createSignal<number | null>(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="Frequently asked questions"
|
||||
description="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14 requirement, offline use — answered straight."
|
||||
ogTitle="The Nook — FAQ"
|
||||
/>
|
||||
<SubdomainHeader />
|
||||
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Questions people actually ask
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-10 max-w-2xl text-lg">
|
||||
The things that decide a purchase, answered without hedging. If
|
||||
something here doesn't match your experience, that's a bug —{" "}
|
||||
<a
|
||||
class="underline underline-offset-2 hover:opacity-80"
|
||||
href="mailto:support@freno.me"
|
||||
>
|
||||
tell us
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<div class="max-w-3xl space-y-3">
|
||||
<For each={FAQ}>
|
||||
{(entry, i) => (
|
||||
<div class="border-overlay0 bg-surface0/40 rounded-2xl border">
|
||||
<button
|
||||
type="button"
|
||||
class="text-text flex w-full items-center justify-between gap-4 p-5 text-left text-base font-semibold"
|
||||
aria-expanded={open() === i()}
|
||||
onClick={() => setOpen(open() === i() ? null : i())}
|
||||
>
|
||||
{entry.question}
|
||||
<span class="text-subtext1 shrink-0 font-mono text-sm">
|
||||
{open() === i() ? "−" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
<Show when={open() === i()}>
|
||||
<p class="text-subtext0 px-5 pb-5 text-sm leading-relaxed">
|
||||
{entry.answer}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user