67 Commits

Author SHA1 Message Date
fixture
e12a063aa9 marketing: FRE-199 third pair of informational pages (drafts) + pitch narrowed to the no-figure shape
Pair 3 (internal target Thu 2026-11-12, six days early):
- agent-worktree-contention — 'How do you manage worktrees when your agents
  end up waiting on each other?' (backlog 1xlz2bs, Scan 6 #4); git worktree +
  Docker Compose project-name docs fetched and read 2026-11-05.
- agent-keeps-asking — 'Why does your coding agent keep stopping to ask you
  questions?' (handback cluster: 1xlfqmo, 1xk0ghj, 1xlxw0q); permission +
  hooks claims re-verified live 2026-11-05 against code.claude.com; the
  1xlfqmo poster's measurement figures deliberately not cited.

Pitch narrowed per CTO reopening verdict: trial→paid placeholder deleted
(FRE-202 cohort too young; ratio waits for FRE-197), second breakage chosen
(NOOK-815 uninvoked suite), any count named as activations, downloads never
quoted.
2026-11-05 22:54:53 +00:00
fixture
0137703341 marketing: FRE-199 CTO review fixes — B1 precedence order, B2 false git-push example dropped, N1 hooks-guide attribution
B1 (agent-waiting-on-permission): body said 'allow / ask / deny' — inverted
vs the permissions doc's deny → ask → allow. Fixed; fact-check row 3 was
already correct and now matches the body.

B2 (agent-run-without-asking): dropped the 'Bash(git commit *) allowed with
Bash(git push *) denied is a policy the agent cannot talk its way around'
example — the docs' own 'What a Bash rule doesn't match' table names
Bash(git push *) as not stopping git -C . push origin main and friends.
Sentence narrowed to the precedence claim, with a pointer to step 4's caveat.

N1 (agent-done-signal): the osascript desktop-notification example is in the
hooks guide (code.claude.com/docs/en/hooks-guide), not the hooks reference.
Body and fact-check row 4 fixed. Pair-1 citations standardised from
docs.anthropic.com to code.claude.com per the review's optional note.

Each page's status blockquote records the revision.
2026-11-05 21:42:26 +00:00
fixture
4dac2e2928 marketing: FRE-199 fact-check verdict applied — second pair drafts/ → approved/
Sponsor fact-check card (FRE-199, interaction a7cba232) answered 2026-11-05
20:21Z: 'Approve both — move to approved/'. Status blockquotes updated to
record the verdict and the date; page bodies untouched.
2026-11-05 20:23:03 +00:00
fixture
680563e3f9 marketing: FRE-199 registry wave-2 brief — targets verified, copy inherited from signed claims
Five wave-2 candidates fetched live 2026-11-05 (awesome-macOS 19.4k ★,
1000.tools, uneed.best, devhunt.org; Product Hunt bot-walled and marked
on-screen-read). Copy inherits FRE-207's CTO-signed claims only; price line
stays 'Free 14-day trial', no number (G2). Submission gated on the wave-1
readback and G3/G4. Copy target was Fri 2026-11-13; ready 2026-11-05.
2026-11-05 20:15:30 +00:00
fixture
c5fb98af8d marketing: FRE-199 second pair of informational pages (drafts)
- agent-parallel-tracking/ — 'How do you keep track of multiple coding
  agents?' — the multi-agent attention cluster (1xmzp2n, 1xh5cum, 1xdjb2g,
  1xipsk0, 1xk0ghj); answered with documented per-session hook events
  (session_id, typed Notification matchers) and the status line.
- agent-run-without-asking/ — 'Should you let your coding agent run without
  asking permission?' — the autonomy cluster (1xexhwf, 1xk8imc, 1xk277d);
  answered with the permission-mode ladder, the docs' own container/VM
  warning, rule precedence, sandboxing pointer, PreToolUse deny.

Internal target Mon 2026-11-09; drafted 2026-11-05. All doc claims re-read
2026-11-05 against code.claude.com (permissions, hooks, statusline). Ban
compliance: no product of ours, no competitor, no handles, no price, no
invented figures.
2026-11-05 19:58:59 +00:00
fixture
77e692273e marketing: FRE-199 fact-check verdict applied — first pair drafts/ → approved/
Sponsor fact-check card (FRE-199, interaction 0101ee80) answered 2026-11-05
19:53Z: 'Approve both — move to approved/'. Status blockquotes updated to
record the verdict and the date; page bodies untouched.
2026-11-05 19:54:45 +00:00
CMO (FrenoCorp agent)
a58b25501a marketing: FRE-199 first pair of informational pages + pitch shape (drafts)
- agent-done-signal: 'How do you know when your coding agent is done?' —
  the zero-looks-like-success cluster (1xjb3j0, 1xm09fs, 1xmnz2c, HN fhub/
  sadilek); Stop/Notification/StopFailure hooks cited to the agent's own
  docs, re-read 2026-11-05. No product, no competitor, no price.
- agent-waiting-on-permission: 'Why is my coding agent sitting on a
  permission prompt?' — the 1xhbc71 thread + invisible-permission cluster;
  permission modes, /permissions, rule syntax, acceptEdits cited to the
  permissions doc; undo-split attributed to 1xkoz06.
- newsletter-postmortem-pitch: item 2's shape, placeholders only; send
  gated on FRE-202 or a CTO-confirmed direct relay-store read.

All drafts: sponsor fact-check moves pending/ -> approved/; nothing
publishes itself.
2026-11-05 19:09:46 +00:00
a0e6ff1044 Merge pull request 'security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371)' (#1) from agent-security-engineer/freno-dev:freno-dev/FRE-371-trusted-client-ip-clean into master
Reviewed-on: #1
2026-11-04 10:58:26 -05:00
6fd0f84806 Merge pull request 'analytics: make each weekly funnel stage separately measurable (FRE-366)' (#2) from agent-code-review/freno-dev:freno-dev/FRE-366-funnel-stages into master
Reviewed-on: #2
2026-11-04 10:58:19 -05:00
c7071b0e32 Merge pull request 'metrics: stand up the weekly funnel job (FRE-202)' (#3) from agent-cto/freno-dev:freno-dev/FRE-202-weekly-metrics into master
Reviewed-on: #3
2026-11-04 10:58:12 -05:00
7fde259e0b Merge pull request 'site: state the agent count as eighteen (FRE-997, count half)' (#5) from freno-dev/FRE-997-count-18 into master
Reviewed-on: #5
2026-11-04 10:57:54 -05:00
25ec84ceb4 site: state the agent count as eighteen (FRE-997, count half)
FRE-359 verdict B dropped the agent count instead of correcting it: the page
said thirteen while `AgentSpec.all` held eighteen, and the /agents table listed
13 of those rows. The count is a fact, not a claim, so this states it and makes
the table match the registry again.

- Landing island bullet and the marquee chip say 18 agents.
- /agents: title/description/ogTitle/H1 at eighteen, and the five missing rows
  are on the page — Kimi, GitHub Copilot CLI, Crush, Cursor, Amp — with events
  and gating taken from hook/setup.go, hook/normalize.go,
  hook/normalize_hosts.go, Sources/NookCore/Agents/HookConfigInstaller.swift
  and plugins/amp/index.ts. All five are Observe (kimi/copilot PreToolUse and
  PermissionRequest fire before the host's own check; Crush documents one
  event; a silent Cursor permission hook blocks; Amp's plugin API has no
  permission-wait event).
- agents.test.ts asserts the roster as an equality with the 18-name registry
  in registry order again, plus the count, instead of a subset check.
- remote-claims.test.ts keeps every FRE-323 remote removal guard and the parked
  copy, and drops only the two completeness bans the corrected count
  contradicts ("every coding agent", "Supported agents") — the ban existed
  because the page claimed the set and listed 13 of 18.
- positioning.md: the coverage row is publishable at 18.

This is the count/roster half of FRE-997 only. The remote-SSH restore is held
by the Chief of Staff against FRE-346 §3 and is not in this commit: no remote
tile, breakdown, demo, chip, /agents remote paragraph or drafts deletion here.

Gate: `bun test src/routes/nook` 18 pass / 0 fail; `bun run build` succeeds.
2026-11-04 00:21:35 +00:00
dd368d21e7 smarter-remote: publish the app's privacy policy and terms of use
A Smarter Remote's iOS paywall sells an auto-renewing subscription, and
App Review 3.1.2 requires it to link a privacy policy and terms of use.
No such pages existed. They now live on the main site at
/smarter-remote/privacy and /smarter-remote/terms, so no new subdomain
or DNS change is needed. The policy describes what the app actually
does: local-network control, pairing data kept on the phone, purchases
through Apple and RevenueCat, and QR sharing phone to phone.

Tests: bun test shows the same 52 pre-existing failures with and
without the pages; tsc reports nothing in the new files.

TVR-19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:34:18 -05:00
2fd2a4a71a security: stop trusting the leftmost x-forwarded-for for rate limits and abuse quotas (FRE-371)
getClientIP (the rate-limit and audit key) took the LEFTMOST x-forwarded-for
hop and read x-real-ip unconditionally. In our topology — client → Cloudflare
→ same-host origin proxy → app — both proxies APPEND, so the leftmost hop is
whatever the sender typed: a rotating X-Forwarded-For minted a fresh rate-limit
bucket per request, and unlimited bug-report quota via the same pattern in
routes/api/the-nook/report.ts. x-real-ip is client-settable too; nothing in
the chain overwrites it. It is no longer read at all.

New trust order (src/server/trusted-client-ip.ts, resolveTrustedClientIp):

  1. a non-loopback socket peer IS the client — its headers are ignored;
  2. cf-connecting-ip (Cloudflare overwrites it at the edge; parsed as an
     address or rejected);
  3. x-forwarded-for counted from the RIGHT, discounting one hop for the
     Cloudflare edge address the origin proxy appended (the topology has
     exactly one trusted proxy below Cloudflare);
  4. "unknown" — callers key stricter shared limits on it, so resolution
     fails closed toward a tighter bucket, never a looser one.

Addresses are never truncated: a /24-truncated key would pool attackers with
the innocents behind them.

Share-vs-copy, stated deliberately: this is a port of the parsing primitives
 FRE-366 reviewed in request-client.ts, NOT an import. resolveClientIp carries
a standing rule (1d9287a) that it never feeds a security decision; lifting
that rule for these callers would couple an access-control change to an
analytics branch under review, and the analytics helper only exposes
truncated prefixes while rate limiting needs the full address. If the two
drift, trusted-client-ip.ts is the one security paths follow. The deploy-side
precondition (origin reachable only via Cloudflare + the same-host proxy) is
documented in the file header; if the app is ever directly internet-reachable
no in-app read can recover the true client.

Tests updated deliberately, not to go green: rate-limit.test.ts now pins the
rightmost-minus-one order, cf-connecting-ip precedence, peer-over-headers,
x-real-ip rejection, and the spoof case (a rotating leftmost hop does not
change the resolved client); report.test.ts pins the quota key stability
under a rotating header. New trusted-client-ip.test.ts covers the helper's
own edges: APIEvent wrapper, loopback forms, unparseable hops failing closed.
2026-10-30 05:06:54 +00:00
Paperclip Agent
1d9287a199 analytics: fix the two FRE-369 findings in the funnel instrumentation
The security review of 39d874a (FRE-369) found the address fix recorded
the wrong thing twice. Both are fixed here; the branch is the same one
under review, this commit does not rewrite it.

Q1 — the first x-forwarded-for hop is attacker-chosen text. In
client → Cloudflare → same-host proxy → app, both proxies *append*, so
the header arrives as `<client spoof…>, <true client>, <CF edge>` and
`split(",")[0]` took the sender's own invention — the loopback-peer gate
was sound, the hop selection defeated it. Preference is now
`cf-connecting-ip` (Cloudflare overwrites it, so a client value never
survives the edge), then x-forwarded-for counted from the right with one
trusted hop discounted, then the direct peer. Every candidate must parse
as an address. h3's `getRequestIP(…, { xForwardedFor: true })` has the
same leftmost flaw and is documented as not a fix.

Q4 — the table held full client addresses indefinitely. Every analytics
entry now passes through `truncateIpAddress` in `enrichAnalyticsEntry`
(IPv4 /24, IPv6 /56, unparseable dropped), so no writer can persist a
full address by accident. Retention got the schedule it never had:
`/api/analytics/retention` on a daily Vercel cron, Bearer-authenticated
and fail-closed, with the window parameterised (the old
`cleanupOldAnalytics` spliced the number into the SQL). Default 365 days
rather than the review's 90 so enabling the cron is not itself a
destructive event — the tighter window is an operational decision, named
on the task.

Also from the review: the module doc now forbids feeding this helper into
any security decision; `isLoopbackAddress` parses instead of
string-matching (`::ffff:127.9.9.9`, `0:0:0:0:0:0:0:1`); and `host` is
normalised (lowercased, port stripped) so `nook.freno.me:443` cannot
silently miss the `host = ?` scope. The weekly loopback caveat matches
the truncated shapes writers produce now, and says plainly that "unique
visitors" counts prefixes.
2026-10-29 18:05:44 +00:00
Paperclip Agent
39d874a7e2 analytics: make each weekly funnel stage separately measurable (FRE-366)
Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.

Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.

Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.

The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
2026-10-29 17:02:39 +00:00
Paperclip Agent
89f56fc78a metrics: stand up the weekly funnel job (FRE-202)
`marketing/metrics/weekly.md` has been a placeholder since the marketing tree
landed — it promised a funnel and the one thing that moved, and nothing
produced it. This adds the producer: one command reads the live databases and
rewrites the file.

`scripts/weekly-metrics.ts` writes `marketing/metrics/weekly.md` from
`VisitorAnalytics` (visits, downloads, campaigns) and the-nook's `trials`,
`licenses`, `activations` (the paid funnel). It needs four env vars —
TURSO_DB_URL/TOKEN and NOOK_DB_URL/TOKEN — and imports no server env, so the
job runs from a checkout that has no Stripe or Apple secrets.
`src/server/weekly-metrics.ts` holds the queries and the renderer, taking both
connections as arguments; the tests run them against an in-memory SQLite shaped
like production, the same way the backfill suite does.

The campaign breakdown groups exactly as `getAnalyticsSummary.topCampaigns`
does (top 10, untagged excluded), so the weekly read and /analytics cannot
drift into different label sets.

The first real report says more about the instrumentation than about demand,
and the renderer computes that rather than asserting it:

- every addressed row the table has ever held (12,035 of 12,048) is loopback,
  so "unique visitors" is one host and per-channel reach cannot be read;
- the landing door and the file route both write `/api/downloads/thenook`, so
  "download click" and "DMG served" are one label — one served click writes two
  rows, a failed click one, a Sparkle update one;
- `POST /api/the-nook/checkout` records nothing, so checkout start is only a
  page-view proxy;
- `VisitorAnalytics` has no host dimension, so the Nook's `/` is freno.me's `/`.

Those three gaps are reported beside the numbers, not papered over. Week 0
(2026-10-22 → 2026-10-29): visit 61, download 156, trial 2, checkout 0,
purchase 0, activation 0 — and the one thing that moved is the download stage,
0 → 156, because the label was introduced that week.

Tests: `bun test src/server/weekly-metrics.test.ts` — 12 pass, 0 fail. The full
suite's 15 failures are pre-existing: this checkout's .env lacks
NOOK_LICENSE_PRIVATE_KEY, NOOK_STRIPE_SK/WEBHOOK/PRICE and LINEAGE_JWT_SECRET,
and every failure is that env validation.

Scripts/ is gitignored, so `scripts/weekly-metrics.ts` was added with `-f`, the
way the existing tracked scripts are.

FRE-202
2026-10-29 15:58:51 +00:00
Paperclip Agent
d44133fcb3 site: guard the removed remote claim and agent count against return (FRE-323, FRE-359) 2026-10-29 14:38:31 +00:00
Freno CMO
578ee58f9c marketing: the remote correction is committed, not deployed
copy.md said the sentences were "removed from the live site" and
rationale.md said "the landing page now says only what the product can back
today". Neither is true: nook.freno.me still serves all of them (HTTP 200,
re-fetched 2026-10-29T14:20Z) because the push is blocked on a write
credential (card 82d1e1b7). Both files now say committed-not-deployed, and
positioning.md's excluded-count row records FRE-359 verdict B and where the
count was removed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-29 14:22:12 +00:00
Freno CMO
d2c2e6a4ee agents: drop the count and the coverage claim (FRE-359 verdict B)
FRE-359's verdict is B: the page's thirteen is the registry's first thirteen
of eighteen rows, so the count, "every coding agent The Nook supports" and
"Supported agents" all overstate or understate the app. The permitted
sentences are the CTO's, verbatim.

- agents.tsx: head description, ogTitle and H1 paragraph replaced with the
  verdict's sentences; title/H1 "Supported agents" -> "Agent coverage".
- agents.test.ts: the stale NOOK-336 guard asserted "all thirteen" and
  the 13-name roster, so it passed in lockstep with the page. It now asserts
  no count of any form, no completeness phrase, and that the rows are a
  subset of the 18-name registry list (copied 2026-10-29, @ f4258034) in
  registry order. The table still lists 13 rows; regenerating it from the
  registry is its own ticket, and the guard no longer pretends otherwise.
- FeatureBreakdowns.tsx / IslandExtras.tsx: the landing "13 agents:" bullet
  and the "13 agents" marquee chip lose the count; the chip becomes
  "Agents wired in".

bun test src/routes/nook/agents.test.ts: 4 pass. Server security and appcast
suites fail in this checkout on missing env (Environment validation failed /
mainDBConnection), unrelated to these files.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-29 14:22:11 +00:00
Paperclip Agent
c718370674 /agents: take the remote-session sentence off the page
FRE-323 removed the remote claim from the landing page and missed the second
page that carries it. /agents said 'Agents can run on this Mac or on remote
machines over SSH — remote sessions report into the same grid', which is the
same unverified claim (FRE-315 §1, FRE-320), in front of the same launch
traffic. Removed; the paragraph now ends at 'the agent keeps its own prompting'.

Not deployed: same read-only credential as c1c498e.
2026-10-29 10:20:13 +00:00
Paperclip Agent
210a7e6e75 marketing: reconcile positioning.md with the CTO verdicts and the price decision
The source-of-truth file still sold claims the board has since ruled on:

- 'thirteen agents' as a public coverage count — FRE-322 §1 counts 18 specs,
  9 of which can raise an approval card; no count is publishable (FRE-203 plan-b).
- 'Price rises to 5 at 1.0' — withdrawn by the Chief of Staff on 2026-10-29
  04:01; price stays $10 and is not a launch asset.
- 'macOS 14+' — the app cannot start below 14.2 (881190d, site 134f41e).
- Approved-claims table carried no launch sentence at all, and no row for the
  remote-SSH hold or the banned absolute privacy sentences.
- Pointed at the-nook/docs/marketing-strategy.md, which no longer exists.

Now: the Plan B claim set, a per-claim status table with verdict pointers,
FRE-354's corrections to the hook/bridge sentence and the Sentry disclosure,
the iPhone TestFlight state, and an explicit excluded/banned list.
2026-10-29 10:19:14 +00:00
Paperclip Agent
c1c498e37f site: remove the unproven remote claim from the Nook landing page
FRE-323. The one-click remote install is app-UI-only and needs hand-edited ~/.ssh/config plus a one-time ssh-copy-id (FRE-320); FRE-315 and FRE-322 hold the claim unverified and demo-gated.

- drop the Remote tile and its mock from the feature collage
- drop the "Any SSH box, one click away" breakdown, its demo and bullets
- drop "Works across every hooked agent, local or remote"
- drop the "One-click SSH setup" chip
- move the copy to marketing/drafts/remote-ssh/ and mark remote held in marketing/positioning.md

No replacement claim was added. Deploy waits on a push credential for the GitHub repo Vercel builds.
2026-10-29 05:55:26 +00:00
134f41eac4 Merge branch 'NOOK-881' — the landing page, FAQ and downloads list said macOS 14, but the app cannot start below 14.2: a CoreAudio overlay symbol its mixer uses only exists from 14.2, and the old bundle floor let LaunchServices launch it on machines that could never run it. The FAQ now names the audio tap API rather than the observation framework, and the downloads list's 14.6+ becomes 14.2+. InputHalo and Gaze keep their own floors. 2026-10-23 13:28:47 -04:00
881190d1d4 nook: say macOS 14.2, which is what the app actually needs
The landing page, the FAQ and the downloads list still promised macOS 14. The
app cannot start there: dyld refuses it on 14.0 and 14.1 because a
CoreAudio overlay symbol its mixer uses only exists from 14.2, and the old
floor in its bundle had LaunchServices launching it on machines that could
never run it (NOOK-881).

The FAQ answer named the observation framework as the reason for 14; the
binding reason is the per-app audio tap API, which Apple shipped in 14.2. The
downloads list said 14.6+, which was already stricter than the truth.

InputHalo and Gaze still read 14.6+; their floors are their own and this
change leaves them alone.

Tests: bun test src/routes/nook (2 pass); bunx tsc --noEmit reports nothing in
the three files changed (the checkout's 286 pre-existing errors are elsewhere).

NOOK-881
2026-10-23 13:28:45 -04:00
f492132bf2 Merge branch 'MISC-48' — download tracking across every door 2026-10-22 12:59:50 -04:00
a524f23be2 downloads: track every door and surface them on /analytics
The analytics page could not answer "how many downloads, of which
product". Three of the four download doors recorded nothing: the tRPC
click proxy only left the generic request row, whose path is the raw URL
with the asset buried in `?batch=1&input={"asset_name":...}`, and the S3
file route that serves Sparkle updates and the Nook app logged nothing
at all. The one door that did log (the Nook redirect) kept a private copy
of the logVisit/enrich/flush sequence, under its own path.

All doors now go through trackDownload, which writes one row per event
under /api/downloads/<asset> and flushes it inside the request — a click
is low-volume and serverless is not guaranteed to run the 30s batch
timer after the response. The file route maps its prefix allowlist to
asset keys, so a version bump stays one asset rather than one per
release. getAnalyticsSummary groups those rows into a downloads block and
the freno.me panel renders it under the overview cards.

History is recovered to the degree it exists: the Nook door's rows and
the tRPC clicks still carry enough to derive from (asset name in the URL).
scripts/backfill-downloads.ts inserts those derived rows with a
`backfill-<source id>` primary key and INSERT OR IGNORE, leaving the
originals untouched, so re-running is a no-op. Run against the live table
it added 66 rows: gaze 42, thenook 19, inputhalo 4, lineage 1.

Tests: new backfill suite (derivation, visitor detail carried over,
idempotency, empty history) and summariseDownloads cases; the Nook route
test now pins the canonical path, and the NOOK-337 allowlist audit reads
the map keys instead of quoted strings. Red-checked by stubbing the
downloads block back to zeros — the summary case fails, then passes.
Full suite matches master.

Co-Authored-By: nw-flash <noreply@nw-flash.local>
MISC-48
2026-10-22 12:59:48 -04:00
0bf15e528d Merge branch 'NOOK-565' 2026-10-17 16:11:43 -04:00
a48326fd1d nook: decode hex apostrophe entities in release notes
Settings > Changelog and /changelog showed literal &#x27; wherever a
bullet had an apostrophe: embed_release_notes.py escapes appcast
descriptions with Python's html.escape, which prefers the hex form for
apostrophes, and decodeEntities only knew the decimal &#39; — so the
entity survived into releases.json and was rendered as-is. Add &#x27;
(and &apos;) to the replacement table.

Tests: new sectionsFromHtml case pinning html.escape-style output;
fails without the fix, full suite otherwise matches master.

Co-Authored-By: glm-5.3-flash <noreply@z.ai>
NOOK-565
2026-10-17 16:11:40 -04:00
ac63057ef7 Merge branch 'NOOK-353' — agents page location cleanup 2026-10-14 10:02:09 -04:00
f6d6d442de site: drop path references from the Nook agents page
The Nook's config, hook binary, plugins and state all live under
~/.nook now, so per-agent filesystem paths on the /agents cards were
both stale and repetitive — every plugin is stored canonically under
~/.nook/plugins and only linked out to each agent's own config dir.
The wiring copy now describes the mechanism, not the location.

The card review also surfaced drift against hook/setup.go, plugins/*
and AgentRegistry: OpenClaw listed AgentResponse, an event renamed to
Stop in NOOK-298, and several cards missed events their integrations
actually emit (SessionError on halted sessions, AgentStart on pi/omp,
PermissionRequest/Resolved on omp, QuestionAsked on hermes, and
Codex's SessionEnd/PreToolUse/PostToolUse). Codex's gating now notes
its user-input questions are answered from a card too.

Tests: bun test (505 pass, 3 skip, 1 pre-existing queryAuditLogs
failure identical to master); tsc --noEmit diagnostics identical to
master.

NOOK-353

Co-Authored-By: kimi-k3-flex <noreply@freno.me>
2026-10-14 10:01:52 -04:00
14a61fde9b Merge branch 'NOOK-336' — NOOK-336 Muse on the agents page 2026-10-13 23:23:44 -04:00
b4e68765fe nook: list Muse on the agents page
nook.freno.me/agents listed twelve agents and said "twelve" in its meta
description, OG title and intro, while the app's registry (AgentSpec.all)
gained Muse in 0.10.0 as its thirteenth. The page mirrors that registry
by hand from another repo, so nothing flagged the drift.

Adds the Muse card from its plugin manifest and registry row: the plugin
body at ~/.nook/plugins/muse installed through Muse's own plugins CLI,
its fourteen hook events, approvals answerable from a card (every tool
call under Verify), and this Mac only since remote hosts skip it. The
positioning claims table and copy move to thirteen with it.

Tests: src/routes/nook/agents.test.ts pins the roster to the registry's
names in order (red on master: Muse missing) and every stated count to
the number of entries (red when the intro says twelve again).

NOOK-336

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:23:43 -04:00
dabfeada3b Merge branch 'NOOK-334' — NOOK-334 landing page feature set 2026-10-13 23:07:14 -04:00
9508aee8e1 nook: show the full feature set on the landing page
The landing page sold eight features from around 0.6 and summed up the
rest in one line, while 0.6–0.10 shipped most of the island's modules. It
also said twelve agents (Muse made thirteen) and marked phone approvals
"coming soon" although the iPhone companion is in TestFlight beta.

Adds live demos for terminal replies, the volume mixer, Capture with its
Wings, and floating actions; a bento of module tiles behind a two-way
feature marquee; and an iPhone/Watch beta section whose request form
posts the same email-only report the app's Settings > Phone card sends.
The approval flow, remote sessions and setup, timer, temperatures and
Capture mocks are rebuilt from the app's own views, so the site shows
what the island actually draws.

Tests: report.test.ts pins the landing page's beta request against the
/report schema (red when the schema demands a machine snapshot); nook and
subdomain suites pass.

NOOK-334

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:07:10 -04:00
1a182e53fe downloads: serve Nook delta files the Sparkle updater fetches
The v0.10.0 publish stopped at the enclosure check: the appcast names
Nook17-9.delta / Nook17-10.delta, and the proxy 400'd both with
'Invalid file format' while the TheNook DMG passed. Sparkle names delta
files after the app bundle, not the DMG, so the prefix allowlist needs
'Nook' alongside 'TheNook'. Both deltas sat in S3 the whole time; the
appcast was never published and the live one still serves 0.9.0.

Adds a static source-audit regression test in the repo's downloads.test
pattern; proven red against the unfixed allowlist.

Tests: bun test src/routes/api/downloads/filename.test.ts

NOOK-337

Co-Authored-By: glm-5.3-flash <noreply@neuralwatt.com>
2026-10-13 22:17:27 -04:00
6898186df5 nessa: retire the API copy now served by nessa-api.freno.me
The Nessa API (nessaDb.*, nessa.community.*, Clerk auth and the Clerk user
webhook) lives in the Nessa repo's server/ on pan. This copy never served
the app: RemoteAPIClient wraps every input in {"input": …}, which the stock
tRPC adapter here reads as the input itself (queries 404, writes no-op).
Clerk's webhook already targets nessa-api.freno.me.

Drops the routers, nessa-auth, the webhook, NessaConnectionFactory, the
NESSA_* env and the now-unused @clerk/backend and svix. Nessa's marketing,
privacy and deletion pages stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:54:14 -04:00
9ff095a82c nook: fill release notes the appcast lost from the archive
0.8.0 and 0.7.0 rendered as a version header over an empty row on
/changelog and in releases.json, the feed the macOS app's Changelog pane
renders. Older still, 0.6.0/0.6.2/0.6.3 were gone from the page outright.

Both come from one gap. The appcast is the only live source and it is not a
store: it holds just the DMGs the release machine keeps, and
generate_appcast drops the description of any rebuilt item whose DMG ships
no notes file of its own — so a release it still lists can arrive with
nothing to show, and one it has aged out of vanishes. The checked-in
archive was meant to cover that, but it stopped at 0.5.0, and mergeReleases
skipped an archived version whenever the appcast mentioned it, so it could
never heal a row that existed and was blank.

The archive now carries every release that shipped notes, 0.9.0 down to
0.2.0, and withArchivedNotes fills an item only where the feed has nothing,
leaving a published description alone. /changelog and releases.json run the
same rule rather than two copies, so the page and the feed cannot disagree
about what a release said. 0.6.1 and 0.3.1 stay absent by design — they
shipped with no notes written.

Tests: bun test src/routes/api/the-nook/ (51 pass; the 4 new cases fail on
this branch's parent); bun test unchanged at 49 failures, all in
src/server/security, which needs an .env a worktree does not carry.
Proved end to end against the live appcast as published by the 0.9.0
release: 11 releases, all with sections.

NOOK-305

Co-Authored-By: qwen3.8-flash <noreply@freno.me>
2026-10-13 00:19:05 -04:00
be90b3d068 nook release changelog api available 2026-10-11 10:19:49 -04:00
cbe56390f8 remove build numbers 2026-10-11 08:26:33 -04:00
26c37c1862 simplified 2026-10-11 08:25:29 -04:00
7108f77bf9 pvp: make the blood token ledger server-authoritative
The PvP_Characters table tallied wins and losses but held no token
balance and no purchase endpoint: earnings were client-reported
(winners credited themselves locally), so verifying only a spend
would have been security theater — a cheater could mint tokens by
self-reporting battle wins.

The server is now the ledger authority (FRE-142): registerCharacter
seeds the balance from the client cache exactly once and reconciles
it on every sync; battleResult requires the verified Lineage JWT
bearer, rejects non-participants, and credits the winner via the
reward formula ported from the client (computed from both registered
rows); a new purchase mutation checks cost, balance, and cooldown in
one conditional UPDATE scoped to the caller-owned row, so the spend
is atomic under concurrency and rate limiting survives cold starts.
Schema changes ship as an idempotent pvp-schema bootstrap
(pvpTokens, ownerUserId, lastPurchaseAt), mirroring the
analytics-schema pattern.

Client-side counterpart shipped in the lineage repo; design record
in its docs/security/pvp-server-side-purchases.md.

Tests: pvp-schema (DDL + legacy-column migration + idempotency),
pvp-rewards (formula parity with the client port, cost table),
pvp-ledger (seed-once reconciliation, owner binding, participant
verification, atomic spend, cooldown, refusal reasons) — 31 new tests
against in-memory SQLite; suite 572 pass (1 pre-existing audit
date-filter failure, unrelated).
2026-10-06 10:56:29 -04:00
687df016a6 nook: defer the SSR stream until the appcast resolves on /changelog
Every cold origin render of /changelog died with ERR_HTTP_HEADERS_SENT:
the pending createAsync at the top of NookChangelogPage suspended the
whole component before <EdgeCacheHeaders> rendered, the shell flushed
while the S3 appcast fetch was in flight, and the boundary re-render
then called setResponseHeader on an already-sent response. Warm renders
hid it because the appcast's 5-minute module cache resolved in a
microtask; fresh Vercel isolates and cache expiry hit the crash, which
is why refreshes and direct navigation failed while in-app Links worked.

deferStream holds the stream until the releases resolve, so HttpHeader
always runs pre-flush. The S3 latency lands on origin renders only,
which the page's CDN-Cache-Control s-maxage=300 already keeps rare.

Tests: local prod-build cold-start loop (build + curl /nook/changelog),
red 3/3 before, green 5/5 after; stack identical to the prod trace.

Fixes FRE-112
2026-10-05 12:40:44 -04:00
92af52dfc9 better changelog capture 2026-10-05 11:05:24 -04:00
53f1413966 nook: render the full release history on /changelog
The appcast retains only recent items, so releases before 0.6.0 had no
surface. The five older sections (0.5.0 back to the first widely
distributed 0.2.0) were recovered from the changelog file's git
history (The-Nook 1ad8a69 / db165cd), converted to the same HTML the
embed step produces, and checked in as an archive the page merges
behind the appcast items — appcast stays authoritative, archive fills
the gaps. 0.3.1 and 0.6.1 shipped without written notes and are absent
by design. Also fixes the accordion snapping 0.6.3 back open when all
sections were collapsed: after any click the state is a version or an
explicit none sentinel, never the initial first-open default.

Tests: tsc clean; dev render shows all eight releases with notes in
the DOM.

Issue: FRE-13
2026-10-05 09:51:08 -04:00
6f41436591 nook: add an appcast upload helper
The release pipeline had no in-repo path to put a corrected appcast
back on S3 (the backfill of 0.6.0/0.6.2 notes needed one). Reads the
same bucket env the appcast route does; refuses to upload unless every
expected release item is present.

Tests: used for the production backfill; endpoint re-verified after.
2026-10-05 09:43:11 -04:00
0b128f6639 nook: claim lifecycle sends so concurrent runs cannot double-mail
The lifecycle job marked rows with an unconditional UPDATE before
sending, so two overlapping invocations (cron tick + manual hit, or
retry) both passed the SELECT, both ran the UPDATE — re-writing the
same value — and both sent the same email. Real duplicates went out to
licensees. Rows are now claimed with a conditional UPDATE (... AND
asked_at IS NULL), and the send happens only when the claim reports
rowsAffected 1; a racing invocation claims nothing and sends nothing.

Tests: lifecycle suite gains a lost-claim case; 36 nook API tests pass.

Issue: FRE-11
2026-10-05 09:38:48 -04:00
37a39b59fe nook: say when a release carries no notes
Releases that shipped before notes were embedded in the appcast
(0.6.2 and earlier) have empty descriptions, so expanding their row
rendered a blank expanse that read as broken. Show a muted "no notes
recorded" line instead; the appcast embed step now runs every release,
so history accumulates from here on.

Tests: vinxi build clean; tsc clean.

Issue: FRE-13
2026-10-05 09:32:22 -04:00
ba6b478320 nook: collapse changelog history into a fixed-height scroller
A long appcast made the page grow with every release and pushed
historical versions far below the fold. Versions now collapse to a
header row inside a fixed-height vertical panel (max-h 70vh): expanding
one never shifts the page, and history is browsed by scrolling inside
it. The newest release starts open; notes stay mounted and toggle via
hidden rather than unmounting, so crawlers still see every entry.

Tests: vinxi build clean; tsc clean.

Issue: FRE-13
2026-10-05 09:27:30 -04:00
35986239c1 nook: dispatch /agents, /changelog and /faq at the top level
The new subdomain pages 404ed in production: the vercel.json host
rewrites do not fire — SSR sees /agents, never /nook/agents — so pages
that only exist under src/routes/nook/* are unreachable from the
subdomain. Every pre-existing subdomain surface survives on top-level
host-dispatch routes (index, privacy, checkout, deletion), which is why
only the new pages broke. Give the three pages the same dispatch,
falling back to 404 on other sites.

Tests: full bun test run unchanged (539 pass); vinxi build clean.

Issue: FRE-12
2026-10-04 23:33:11 -04:00
b41cf83879 marketing: add the scaffolding and positioning source of truth
One voice, one place, so agent-drafted assets stop inventing framing
per artifact. positioning.md carries the wedge, the category-noun rule
(freno-dev/AGENTS.md competitor ban), claims each backed by code, the
honest privacy scoping and the voice/banned-phrasings list. The tree
adds calendar.json, per-channel briefs, drafts → pending → approved →
posted, metrics and listening — with the hard rule up front: approved/
is the only thing a human ever publishes from, nothing posts itself.

Issue: FRE-17
2026-10-04 23:22:28 -04:00
f66fd43f62 nook: capture trial email and run the two lifecycle emails
Trials were anonymous — a 14-day trial you cannot talk to. trials gains
an email column (idempotent ALTER), POST /api/the-nook/trial-email
attaches the optional first-launch prompt's answer, and the address
lands in a Brevo list when NOOK_TRIAL_LIST_ID is set. The lifecycle
emails follow: a D+11 trial expiry reminder with a buy link, a purchase
thank-you from the Stripe webhook, and scheduled testimonial (D+3) and
review (D+14) asks via the daily /api/the-nook/lifecycle cron. Rows are
marked before sending so retries never double-send; trial addresses
that already bought get no buy reminder; the endpoint requires a
Bearer secret (CRON_SECRET / NOOK_LIFECYCLE_SECRET) and fails closed
when unconfigured — an open trigger would email real users. All sends
are best-effort and logged, never thrown.

Tests: trial-email (3) and lifecycle (6) suites; full run 539 pass.

Issues: FRE-10, FRE-11
2026-10-04 23:22:24 -04:00
421fb946fc 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
2026-10-04 23:22:09 -04:00
dc959d971b nook: fix the landing title, isolate /blog, grow the sitemap
The landing page spent its strongest on-page signal on the word "Home";
the title now carries the positioning line and the OG title carries the
full brand phrase. /blog on a product subdomain served the personal blog
in dev (vinxi ignores the vercel.json rewrites that protect production),
splitting the brand and crawl equity — subdomain hits now render the
shared 404. /agents, /changelog and /faq join the sitemap registry so
they are indexed as they land.

Tests: bun test (539 pass); vinxi build clean, no new tsc errors.

Issues: FRE-8, FRE-15, FRE-9
2026-10-04 23:22:04 -04:00
44bf21a868 fix pause error 2026-10-04 08:48:32 -04:00
dd63da30c4 og image 2026-10-03 15:01:23 -04:00
2fb5097b85 update package name 2026-10-03 11:04:37 -04:00
96e401ecbb nook marketing - utm, analytics, etc 2026-10-03 11:03:26 -04:00
3fe3516b9e nook helper 2026-09-22 22:22:13 -04:00
bef8f8414b utilization reduction 2026-09-22 16:38:43 -04:00
f3e9cdf0bd fix sentry + note email delivery 2026-09-06 13:53:49 -04:00
7ecc0f68d7 increase width cap 2026-09-06 01:08:52 -04:00
00a26a9e49 resized+compressed 2026-09-06 01:06:58 -04:00
163c68fcb8 new with temp demo 2026-09-05 20:02:10 -04:00
3149eba196 fix missing height 2026-09-05 17:20:12 -04:00
bc65789431 fix mobile overlap 2026-09-05 16:59:36 -04:00
21f1f07a99 fix download routing, fix layout shift 2026-09-05 16:45:40 -04:00
173 changed files with 12987 additions and 6698 deletions

View File

@@ -55,15 +55,8 @@ TURSO_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
TURSO_DB_API_TOKEN="<rotate-in-turso-dashboard>" # org-level API token
TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io"
TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>"
NESSA_DB_URL="libsql://<nessa-db>.turso.io"
NESSA_DB_TOKEN="<rotate-in-turso-dashboard>"
# Clerk authentication — rotate via Clerk Dashboard or `clerk api .../rotate_secret_keys`
NESSA_CLERK_SECRET="sk_test/live_<rotate-in-clerk-dashboard>" # secret key (sk_test_... for dev, sk_live_... for prod)
NESSA_CLERK_JWT_ISSUER="https://<your-app>.clerk.accounts.dev" # JWT issuer from Clerk Dashboard
NESSA_CLERK_WEBHOOK_SECRET="whsec_<rotate-in-clerk-dashboard>" # Svix signing secret from Clerk Dashboard → Webhooks → Signing Secret
APPLE_CLIENT_ID_LINEAGE=com...
APPLE_CLIENT_ID_NESSA=com...
# ── Infra / integration tokens ──
INFILL_BEARER_TOKEN="<rotate-at-infill-service>"
@@ -83,3 +76,13 @@ NOOK_LICENSE_PRIVATE_KEY="<generate-license-keypair>" # base64 PKCS8
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"
# ── The Nook lifecycle emails ──
# Daily cron (vercel.json → /api/the-nook/lifecycle) requires a Bearer secret.
# Set CRON_SECRET and Vercel attaches it automatically to scheduled runs.
# Generate: openssl rand -base64 64 | tr -d '\n'
CRON_SECRET="<openssl-rand-base64>"
# Optional override for the secret the endpoint accepts (falls back to CRON_SECRET).
NOOK_LIFECYCLE_SECRET=""
# Optional Brevo list id trial-start emails are added to (the lifecycle audience).
NOOK_TRIAL_LIST_ID=""

View File

@@ -86,7 +86,7 @@
This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration.
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy` → `/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.)
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy` → `/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.) **Every subdomain surface requires both files:** the `src/routes/<prefix>/<name>.tsx` content component AND a matching top-level dispatch file (`src/routes/<name>.tsx` or `src/routes/<name>/index.tsx`) that imports and dispatches to it — without the dispatch file, the page is unreachable on the subdomain. The `src/routes/subdomain-dispatch.test.ts` suite enforces this automatically on every `bun test` run.
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
- **Auth:** Host-scoped only — no cookie domain broadening.

View File

@@ -10,21 +10,7 @@ export default defineConfig({
org: "mikefreno",
project: "freno-dev",
authToken: process.env.SENTRY_AUTH_TOKEN,
telemetry: false,
sourcemaps: {
assets: [
{
type: "bundle",
path: "dist/client/assets/",
urlPrefix: "~/assets/"
},
{
type: "sourcemap",
path: "dist/client/assets/",
urlPrefix: "~/assets/"
}
]
}
telemetry: false
})
],
build: {

BIN
bun.lockb

Binary file not shown.

33
marketing/README.md Normal file
View File

@@ -0,0 +1,33 @@
# Marketing tree
One voice, one place. Every later artifact draws on `positioning.md` — do not
invent framing per-asset.
```
positioning.md # source of truth: wedge, voice, banned phrasings, approved claims
calendar.json # scheduled pushes: date, channel, status
briefs/<channel>.md # per-channel rules, format, limits, what "good" looks like
drafts/<id>/ # agent output: copy.md, assets/, rationale.md
pending/ approved/ posted/ # human gate by directory move
metrics/weekly.md # funnel snapshot, generated
listening.md # buying-intent threads + drafted replies
```
## Hard rule
**`approved/` is the only thing a human ever publishes from. Nothing posts itself.**
Workflow: agent drafts into `drafts/<id>/` → moves to `pending/` when complete →
a human reviews and moves it to `approved/` → the human publishes from `approved/`
under their own account → the artifact (or a pointer) lands in `posted/`.
Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means.
## The weekly read
`bun --env-file=.env scripts/weekly-metrics.ts` (or `bun run metrics:weekly`)
rewrites `metrics/weekly.md` from the live databases: the staged funnel, the
campaign breakdown (`getAnalyticsSummary.topCampaigns`' grouping), and the one
stage that moved. It needs `TURSO_DB_URL`, `TURSO_DB_TOKEN`, `NOOK_DB_URL` and
`NOOK_DB_TOKEN` — no other production secret. The file is generated; never
hand-edit it. The numbers are engineering's; the interpretation is the CMO's.

View File

@@ -0,0 +1 @@
# approved

View File

@@ -0,0 +1,92 @@
# How do you know when your coding agent is done?
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 fact-check
> card, verdict: "Approve both — move to approved/"). Not published; a human
> places it on nook.freno.me from `approved/`.
> This page never names a product of ours and never names a competitor.
> Internal target: first page of the FRE-199 pair, Thu 2026-11-06.
> CTO review 2026-11-05: changes requested and applied — the `osascript`
> example is attributed to the hooks guide, not the hooks reference; pair-1
> citations standardised to code.claude.com.
Often, you don't — not from the terminal alone. A coding agent's turn can end in
silence: the prompt returns, the cursor blinks, and a finished turn looks exactly
like one that stalled, is still running, or is waiting on you. The fix is to stop
trusting silence and wire the two moments that matter — *the turn ended* and *the
agent needs you* — to something you will actually see.
## Why silence is the failure mode
A terminal coding agent ends a turn by printing a prompt. If you are in another
window, another room, or running several agents at once, nothing in that output
distinguishes "finished" from "blocked on a permission prompt" from "dead". The
task being done and you finding out are two different events, and the second one
has no default signal.
Practitioners keep rediscovering this. One operator running a self-hosted queue
of autonomous agent sessions described sessions that "finished their work but
never sent the done signal, and held their slots for hours" — their fix was to
derive "done" from git state plus an idle transcript instead of trusting the
agent's own signal. A public service post about a code-review plugin described
the mirror image: a green checkmark and "No findings" when the review had not
run at all. An empty result and a successful result rendered identically.
That is the trap in one sentence: **zero looks exactly like success.**
## What the turn-ending signal actually is
Claude Code gives you two hooks that bracket the moments worth surfacing:
- **`Stop`** fires when the agent finishes responding — the end of a turn.
- **`Notification`** fires when Claude Code sends a notification. A permission
prompt is one of these: the hook's payload carries a `notification_type` of
`permission_prompt` and a message like "Claude needs your permission".
- **`StopFailure`** fires instead of `Stop` when the turn ends on an API error —
rate limits, authentication problems, and the like.
All three can run a command of your choosing. The hooks reference documents the
events; the hooks guide includes a desktop-notification example built on
macOS's `osascript`.
## What to check or do
1. **Wire turn-end to a notification you will actually notice.** A `Stop` hook
that calls `osascript -e 'display notification …'` (or your terminal's
notification escape) turns the silent prompt-return into a visible event.
A terminal bell is not this — it is mute the moment you switch windows.
2. **Route permission waits separately from completions.** The `Notification`
hook's payload tells you which kind it is. A permission wait means the agent
is *blocked on you*; treating it as "done" hides the one state that costs the
most time.
3. **Don't trust the exit signal alone.** Check the artifacts: `git status`,
`git log`, whether the transcript has gone idle. The queue operator's rule
generalises — a "done" that has to name what it is waiting on is a "done"
you can act on.
4. **Surface the failure path too.** A turn that ends on an API error fires
`StopFailure`, not `Stop` — an alert wired to the wrong one misses exactly
the runs that need attention.
One next step: take the agent command you run most often and give its end-of-turn
a notification you would notice from across the room. That single wire closes the
most expensive silence in the workflow.
---
## Fact-check table (not part of the page)
Every technical claim above, with its primary source. Claims about Claude Code's
behaviour come from its own documentation, re-read 2026-11-05.
| # | Claim in the page | Source |
|---|---|---|
| 1 | `Stop` fires when the agent finishes responding (end of a turn); hook cadence is "per turn: UserPromptSubmit, Stop, StopFailure" | Claude Code hooks reference — https://code.claude.com/docs/en/hooks |
| 2 | `Notification` fires "when Claude Code sends a notification"; example payload carries `notification_type: "permission_prompt"`, message "Claude needs your permission" | same hooks reference (Notification event section) |
| 3 | `StopFailure` "runs instead of Stop when the turn ends due to an API error" (rate limits, authentication) | same hooks reference (StopFailure section) |
| 4 | Hooks run a command of your choosing; a desktop-notification example using `osascript` is in the hooks guide | Claude Code hooks guide — https://code.claude.com/docs/en/hooks-guide (desktop-notification example) |
| 5 | Operator anecdote: sessions that finished work but never signalled done, holding slots for hours; fix = derive done from git + idle transcript | r/ClaudeCode thread 1xjb3j0 ("Are you running a self-hosted queue of autonomous Claude Code sessions?", 2026-10-31), quoted in `drafts/listening.md` Scan 4 addendum. Quoted as one operator's described practice, not a measurement. |
| 6 | PSA anecdote: code-review plugin showed green check + "No findings" without running | r/ClaudeCode 1xmnz2c (2026-11-03), logged in `drafts/listening.md` Scan 6. |
| 7 | "Zero looks exactly like success" | Verbatim buyer phrasing, three independent instances (1xm09fs, 1xmnz2c, plus HN repeats) in `drafts/listening.md` Scans 5–8. Used as the problem's name, attributed to no one. |
**Ban compliance:** no product of ours named · no competitor named · no
superlatives · no benchmark numbers · no invented testimonials (anecdotes are
real threads, described without handles) · no price · no roadmap dates.

View File

@@ -0,0 +1,72 @@
# Rationale — "How do you know when your coding agent is done?"
FRE-199 item 1, page 1 of the first pair. Drafted 2026-11-05 by CMO.
## The phrase
Primary: **"how do you know when your coding agent is done"** (variants: "agent
finished silently", "claude code waiting for permission" overlaps on the
blocked-half; this page owns the *done*-half).
Chosen by the FRE-199 selection rule: strongest buying intent we can answer
honestly without naming a product. It is on the standing phrase list
(`drafts/listening.md` §The phrase list: "how do I know when my agent is done").
## Why this phrase won the slot
- **Recurrence with independence.** The "zero looks exactly like success"
pattern appears in at least four separate sources across Scans 5–9: 1xm09fs,
the code-review PSA (1xmnz2c), and two HN commenters (fhub, sadilek), plus
the queue operator's done-signal workaround (1xjb3j0). Four independent
formulations of one pain in seven days is the strongest recurrence the
backlog shows.
- **Buying intent.** The people describing it are running agents unattended
right now and are losing time to the gap — they are past curiosity.
- **Answerable honestly, generically.** The mechanism (Stop / Notification /
StopFailure hooks) is documented in the agent's own reference; every claim in
the page traces to it. Nothing about our product is needed to answer it.
- **Wedge-aligned.** "An agent blocked is the most expensive kind of idle" —
the done-half of that sentence (idle you never learn about) is this page.
## Evidence trail
| Thread | Date | What it showed |
|---|---|---|
| r/ClaudeCode 1xjb3j0 — "self-hosted queue of autonomous Claude Code sessions" | 2026-10-31 | Sessions "finished their work but never sent the done signal, and held their slots for hours"; operator derives done from git + idle transcript; "a waiting one has to name what it's waiting on" |
| r/ClaudeCode 1xm09fs | 2026-11-02 | First "zero looks exactly like success" instance (verbatim buyer phrasing) |
| r/ClaudeCode 1xmnz2c — code-review plugin PSA | 2026-11-03 | Green check + "No findings" when the review never ran; third independent instance |
| HN comments (fhub, sadilek) | 2026-11-04/05 | Human-attention concurrency ceiling quantified twice more |
All rows live in `drafts/listening.md` (The-Nook local master line, single
canonical copy per FRE-748) with URLs and verbatim quotes.
## Gates before this page ships
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
Freno). Page moves `pending/` → `approved/` on his read, per the marketing
tree's hard rule.
2. **CTO eyes on the agent-behaviour claims** — all four are sourced to the
Claude Code hooks reference re-read 2026-11-05, but anything asserted about
an agent's behaviour is CTO-verifyable, and the FRE-199 plan routes it to
him.
3. **Publish path:** `pending/` → `approved/` → a human places it on
nook.freno.me (blog route). Nothing posts itself.
## Ban compliance (FRE-199 + positioning.md rule 1)
- The Nook: not named. The notch, the island, the companion: not mentioned.
- Competitors (notch apps, agent monitors, Remote Control–style tools): not
named. Remote Control is not mentioned either — the page answers the done-half
without touching the away-from-desk half.
- Claude Code is named only as the subject whose documented behaviour the page
explains, with its own docs as the source — the same status the listening
phrase list already gives it. It is the agent, not a competitor.
- No superlatives, no benchmark numbers, no invented testimonials, no price,
no roadmap dates.
## Cadence note
This page and `agent-waiting-on-permission/` are the first pair. The queue
behind them (multiple-agents tracking, invisible-permission cluster) holds well
above the three-candidate floor, so the two-a-week cadence holds without
inventing phrases.

View File

@@ -0,0 +1,113 @@
# How do you keep track of multiple coding agents?
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 pair 2
> fact-check card, verdict: "Approve both — move to approved/"). Not published;
> a human places it on nook.freno.me from `approved/`. Internal target:
> Mon 2026-11-09, met four days early. This page never names a product of ours
> and never names a competitor.
The honest answer: not by watching them. Run three agents and the watching
already fails — the session you are not reading is the one that finished, or
the one sitting on a question, and both states look identical from the outside.
The fix is not more windows; it is giving each agent a way to announce its own
state, so checking becomes something that happens to you instead of something
you have to remember.
## Why attention is the limit
Parallel agents are cheap to start and expensive to supervise. Every running
session can stop and wait for you — a permission prompt, a question, a finished
turn — and from your side of the screen those are invisible until you go look.
Operators who run agents at scale keep describing the same wall in different
words. One practitioner running dozens of parallel threads a day put it as the
limit moving "from tokens to my attention"; another, asked how many sessions
they could run, got the answer from their own comment section: not compute —
you. A developer who ran agents unattended for a month summarised the failure
as silence: "the six hours of silence was the real problem, not the dialog
itself." And the most common collision is the simplest — two agents waiting on
you while you are deep in the third, and one of them idle so long you lost the
thread of what you asked it to do.
None of that is a speed problem. It is a visibility problem: *which* of your
agents needs you, and *how do you find out without touring every window?*
## What a per-agent signal actually is
A terminal coding agent has no shared dashboard by default — each session is
its own process, its own window. But its state changes are events, and events
can be wired outward. Claude Code documents the pieces:
- **Every state you care about fires an event.** `Stop` fires when Claude
finishes responding — the end of a turn. `Notification` fires when Claude
Code sends a notification, and the notification's type tells you which kind
of waiting it is: a `permission_prompt`, an `idle_prompt`, `agent_needs_input`,
or `agent_completed`. `SessionStart` and `SessionEnd` bracket the whole
session.
- **The event names the session it came from.** Every hook payload carries a
`session_id`, so one handler can serve every window and still know which
agent fired.
- **A hook is just a command you choose.** The events are configured in the
settings files and run whatever command you point them at — append to a log
file, update a status line, send a desktop notification that names the
project.
- **The status line can carry per-session state.** Claude Code's status line
is a customizable bar driven by a script you write; it receives session data
on every render, and the docs name "work across multiple sessions and need
to distinguish them" as a reason it exists.
The pattern in all four: stop touring windows and make each agent's
running / waiting / finished state observable where you already are.
## What to check or do
1. **Name the states before naming the tools.** For your workflow the states
are usually three — running, waiting on you, finished — and the expensive
one is always "waiting on you". A tracking setup that cannot distinguish a
permission wait from a completion will quietly hide the state that costs
the most.
2. **Wire the waiting events first.** A `Notification` hook matched on
`permission_prompt` and `agent_needs_input` catches the blocked cases; the
payload's `session_id` lets one handler label each alert with the project
it came from.
3. **Route completions separately.** `Stop` means the turn ended — not that
the work was right, and not that nothing is blocked elsewhere. Treating
"finished" and "needs you" as one notification is how a waiting agent gets
buried under a pile of done messages.
4. **Make the state visible without switching windows.** A per-session status
line, a log file the hook appends to, or a desktop notification that names
the project all work — the test is whether you can answer "does anything
need me?" without leaving the window you are in.
5. **Expect the ceiling to be yours.** Operators consistently find the
bottleneck before the machines do. The number of agents you can run is the
number whose waiting state reaches you reliably — not the number your
terminal can host.
One next step: run two agents side by side and wire only their permission-wait
events to a notification that names the project. The first time the second
agent's prompt reaches you before you happen to check its window is the whole
argument.
---
## Fact-check table (not part of the page)
Every technical claim above, with its primary source. Claims about Claude
Code's behaviour come from its own documentation, re-read 2026-11-05.
| # | Claim in the page | Source |
|---|---|---|
| 1 | `Stop` fires when Claude finishes responding; `SessionStart`/`SessionEnd` are per-session events | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (hook events table; "per session: SessionStart and SessionEnd; per turn: UserPromptSubmit, Stop, and StopFailure") |
| 2 | `Notification` fires when Claude Code sends a notification; matcher supports notification types including `permission_prompt`, `idle_prompt`, `agent_needs_input`, `agent_completed` | same hooks reference (matcher table: "Notification | notification type") |
| 3 | Every hook payload carries `session_id`, the current session identifier | same hooks reference (Common input fields: `session_id` — "Current session identifier") |
| 4 | Hooks are configured in the settings files and run a command of your choosing | same hooks reference (configuration and command-hook examples) |
| 5 | The status line is a customizable bar driven by a script, receives JSON session data, and the docs name "work across multiple sessions and need to distinguish them" as a use | Claude Code status line doc — https://code.claude.com/docs/en/statusline |
| 6 | "The limit moved from tokens to my attention" / "fifty a day add up to something I could feel by Friday" | r/ClaudeCode 1xh5cum (2026-10-27, "Four months of 50+ parallel agent threads a day"), logged in `drafts/listening.md` Scan 3. Practitioner's own account, not a measurement. |
| 7 | "How many sessions before you become the bottleneck" — the thread went to compute; the attention half stayed on the table | r/ClaudeCode 1xdjb2g (2026-10-23, 72 comments), logged in `drafts/listening.md` Scan 1. |
| 8 | "The six hours of silence was the real problem, not the dialog itself" | r/ClaudeCode 1xipsk0 (2026-10-29, "~40 Claude Code agents unattended for a month"), quoted by the OP in-thread, logged in `drafts/listening.md` Scan 2. |
| 9 | Two agents waiting while the human is in the third; idle so long the thread of the task was lost; "most of my idle time came from agents waiting on a permission prompt while I was in another window" | r/ClaudeAI 1xmzp2n (2026-11-03, "How do you keep track of multiple coding agents running in parallel?"), two independent commenters, logged in `drafts/listening.md` Scan 9. |
| 10 | "You're the bottleneck agents were meant to eliminate" — approvals routing through one person | r/ClaudeCode 1xk0ghj (2026-10-31, "multiple agents working on multiple projects"), commenter's words, logged in `drafts/listening.md` Scan 4. |
**Ban compliance:** no product of ours named · no competitor named (herdr,
tmux, dashboards: none named — the page answers generically) · no handles · no
superlatives · no benchmark numbers · no invented testimonials (anecdotes are
real threads, described without handles) · no price · no roadmap dates.

View File

@@ -0,0 +1,71 @@
# Rationale — "How do you keep track of multiple coding agents?"
FRE-199 item 1, page 1 of the second pair. Drafted 2026-11-05 by CMO (internal
target Mon 2026-11-09, beaten by four days — the backlog held, so drafting
early cost nothing).
## The phrase
Primary: **"how do you keep track of multiple coding agents running in
parallel"** (variants: "how many sessions before you become the bottleneck",
"which agent is waiting", "agent tracking dashboard").
## Why this phrase won the slot
- **Strongest recurrence in the backlog.** Seven independent threads across
nine scans state the multi-agent attention problem in their own words — more
than any other cluster after the pair-1 phrases. It is the natural next page:
the pair-1 pages answer *one* agent's silence (done-signal) and *one*
permission wait (waiting-on-permission); this is the fleet-scale version of
the same pain, and the buyers are already at fleet scale.
- **Buying intent.** Every quoting operator is running multiple agents *now*
and losing time to supervision, not exploring.
- **Answerable honestly, generically.** The mechanism (per-session hook events
with `session_id`, typed notifications, the status line) is documented in the
agent's own reference; every claim traces to it. No product needed.
- **Distinct from pair 1.** Done-signal owns "one agent, is it finished";
waiting-on-permission owns "one agent, why is it blocked". This page owns
"several agents, which one needs me" — the phrase, the evidence, and the
mechanism (typed Notification matchers, `session_id`, status line) do not
overlap pair 1's.
## Evidence trail
| Thread | Date | What it showed |
|---|---|---|
| r/ClaudeAI 1xmzp2n — "How do you keep track of multiple coding agents running in parallel?" | 2026-11-03 | The exact question as a fresh thread: "How do you notice when one is waiting, done, or stuck?"; two commenters independently: agents "sat waiting on a permission prompt while I was deep in the third", idle "long enough that I lost the thread of what I had asked it to do" |
| r/ClaudeCode 1xh5cum — "Four months of 50+ parallel agent threads a day" | 2026-10-27 | "The limit moved from tokens to my attention"; "fifty a day add up to something I could feel by Friday" |
| r/ClaudeCode 1xdjb2g — "How many sessions can you run before you become the bottleneck?" | 2026-10-23 | 72 comments; the thread went to compute and left the attention half open |
| r/ClaudeCode 1xipsk0 — "~40 Claude Code agents unattended for a month" | 2026-10-29 | "The six hours of silence was the real problem, not the dialog itself" |
| r/ClaudeCode 1xk0ghj — "multiple agents working on multiple projects" | 2026-10-31 | "If every decision routes through you, you're the bottleneck agents were meant to eliminate" |
| Hacker News 50298436 (herdr launch) | 2026-10-30 | A 30-year tmux user's stated switch reason: "visual indication which agent is working and which is waiting for an input" — used in rationale only; the page does not name any tool |
All rows live in `drafts/listening.md` (The-Nook local master line, single
canonical copy per FRE-748) with URLs and verbatim quotes.
## Gates before this page ships
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
Freno). Page moves `drafts/` → `pending/` → `approved/` on his read, per
the marketing tree's hard rule.
2. **CTO eyes on the agent-behaviour claims** — five doc-sourced claims
(hooks reference ×4, statusline doc ×1), all re-read 2026-11-05. Routed to
the CTO per the FRE-199 plan.
3. **Publish path:** `approved/` → a human places it on nook.freno.me.
Nothing posts itself.
## Ban compliance (FRE-199 + positioning.md rule 1)
- The Nook: not named. The notch, the island, the companion: not mentioned.
- Competitors: not named. The herdr evidence is rationale-only; the page's
"what to check" recommends no tool at all — states and wiring, not products.
- Claude Code is named only as the subject whose documented behaviour the page
explains, with its own docs as the source — same status as pair 1.
- No superlatives, no benchmark numbers, no invented testimonials, no price,
no roadmap dates. Anecdotes are real threads, described without handles.
## Cadence note
Second pair. Queue behind it: the run-without-asking cluster (same pair), the
fan-control phrase, and the invisible-permission variants — above the
three-candidate floor. Two a week holds.

View File

@@ -0,0 +1,102 @@
# Should you let your coding agent run without asking permission?
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 pair 2
> fact-check card, verdict: "Approve both — move to approved/"). Not published;
> a human places it on nook.freno.me from `approved/`. Internal target:
> Mon 2026-11-09, met four days early. This page never names a product of ours
> and never names a competitor.
> CTO review 2026-11-05: changes requested and applied — the `Bash(git commit *)`
> allowed / `Bash(git push *)` denied example removed (the docs' own
> "What a Bash rule doesn't match" table shows a Bash rule does not stop every
> invocation); the precedence claim stands.
You can — and the honest answer is that "should" is not one decision but two:
what you are letting it do, and what happens when it does the wrong thing.
Every operator who runs agents unattended converges on the same split — a free
hand on what git can undo, a confirmation on what it can't — and the agent's
own permission modes exist to draw that line somewhere you choose, not
somewhere a flag chooses for you.
## The mechanism: a spectrum, not a switch
Claude Code's permission modes form a ladder from "ask for everything" to "ask
for almost nothing", and each rung has a documented meaning:
- **`default`** prompts on the first use of each tool.
- **`acceptEdits`** auto-accepts file edits and common filesystem commands
(`mkdir`, `touch`, `mv`, `cp`) inside the working directory.
- **`plan`** reads files and runs read-only shell commands but does not edit
your source files — the agent explores, you approve the plan.
- **`auto`** runs without routine prompts; before actions such as shell
commands and network requests run, a background classifier checks that they
align with your request.
- **`bypassPermissions`** skips permission prompts, except for actions no mode
auto-approves.
Two documented facts should shape where you stop on that ladder. First, the
docs' own warning on the top rung: in `bypassPermissions` mode Claude Code
skips prompts — including for writes to protected paths such as `.git` — and
the mode belongs "in isolated environments like containers or VMs where Claude
Code can't cause damage". Second, enforcement is real, not advisory: the docs
state plainly that "permission rules are enforced by Claude Code, not by the
model" — a `CLAUDE.md` instruction asking nicely for permission changes
nothing.
## What to check or do
1. **Decide by blast radius, not by annoyance.** The question "how do I get
fewer prompts?" tempts a jump to the top of the ladder. The better
question: which of this agent's actions would be *undoable* if wrong? Edits
inside a git worktree are; `rm -rf` on the wrong path, a pushed commit, and
a message sent to a third party are not.
2. **Move down the ladder one rung at a time.** `acceptEdits` in a worktree
covers most of the prompt volume with the undo still in reach. If you
consider `auto` or `bypassPermissions`, the docs' own container-or-VM
condition is the bar: isolation first, autonomy second.
3. **Write the rules instead of trusting the mode.** Rules are evaluated in
order — deny, then ask, then allow — and the first match wins, so a broad
deny holds even against a narrower allow. (What a Bash rule cannot do is
match every way the same binary gets invoked — see the next step.)
4. **Know what the rules do not cover.** A Bash rule matches the command text
Claude writes, and the docs are explicit that this "isn't a security
boundary around the program" — the same binary invoked by another path is
not matched. For filesystem and network enforcement that does not depend
on command text, the docs point at sandboxing.
5. **Use hooks as the programmable layer.** A `PreToolUse` hook sees every
tool call before it runs and can return a `deny` decision — the reference
builds one that blocks `rm -rf`. A silent hook does not approve anything;
the call just continues through the normal permission flow.
6. **Leave the failure path a way to reach you.** An unattended run that hits
a prompt nobody sees stops silently — that is the failure mode of unattended
work, not of permissions. Whatever mode you pick, wire the
"waiting on you" event somewhere you will actually notice.
One next step: write down the three actions your agent takes that you could
not undo, and put them in `deny` or `ask` rules today. Whatever mode you run
above them, those three now require a human — which is the whole point of the
ladder.
---
## Fact-check table (not part of the page)
Every technical claim above, with its primary source. Claims about Claude
Code's behaviour come from its own documentation, re-read 2026-11-05.
| # | Claim in the page | Source |
|---|---|---|
| 1 | Mode meanings: `default` prompts on first use; `acceptEdits` auto-accepts file edits and common filesystem commands (`mkdir`, `touch`, `mv`, `cp`) in the working directory; `plan` reads files and runs read-only shell commands but doesn't edit source; `auto` runs without routine prompts with a background classifier checking alignment; `bypassPermissions` skips prompts except actions no mode auto-approves | Claude Code permissions doc — https://code.claude.com/docs/en/permissions (Permission modes table) |
| 2 | Docs' warning: in `bypassPermissions` mode Claude Code skips prompts including for writes to protected paths such as `.git`; "Only use this mode in isolated environments like containers or VMs where Claude Code can't cause damage" | same permissions doc (warning under the modes table) |
| 3 | "Permission rules are enforced by Claude Code, not by the model" | same permissions doc (note under Manage permissions) |
| 4 | Rules evaluated in order deny → ask → allow; first match wins; a broad deny holds against a narrower allow | same permissions doc (Manage permissions) |
| 5 | A Bash rule matches the command text Claude writes and "isn't a security boundary around the program" (`Bash(curl *)` doesn't stop `/usr/bin/curl …`); for filesystem/network enforcement independent of command text the docs point at sandboxing | same permissions doc (What a Bash rule doesn't match) |
| 6 | A `PreToolUse` hook fires before a tool call executes, can block it, and can return a `permissionDecision` of `"deny"`; the reference builds a hook that blocks `rm -rf`; a silent hook does not approve — the call continues through the normal permission flow | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (PreToolUse decision control walkthrough) |
| 7 | Undo-split practice ("free hand on what git can undo, confirmation on what it can't") | r/ClaudeCode 1xkoz06 (2026-10-31), commenter's described practice, logged in `drafts/listening.md` Scan 3 addendum; also r/ClaudeCode 1xexhwf commenters (VM, worktrees, blast-radius split). Described as operator practice, not a rule. |
| 8 | An unattended run that hits a prompt nobody sees stops silently | r/ClaudeCode 1xipsk0 (2026-10-29), commenter: "A run started from launchd or a background service can hit a prompt that no one will ever see", logged in `drafts/listening.md` Scan 2. |
| 9 | The trust asymmetry: comfortable with a model writing code, "considerably less comfortable giving an autonomous agent broad access to my development environment" | r/ClaudeCode 1xk8imc (2026-10-31, "Has anyone built a safe, autonomous coding workflow…?", 43 comments), OP's words, logged in `drafts/listening.md` Scan 4. |
| 10 | The operator's own safety menu: "committing before every run? containers? `--dangerously-skip-permissions` and hoping?" | r/ClaudeAI 1xk277d (2026-10-31, "How often has a coding agent done something you had to undo?"), OP's words, logged in `drafts/listening.md` Scan 4. Quoted as the question's shape, not as guidance. |
**Ban compliance:** no product of ours named · no competitor named · no
handles · no superlatives · no benchmark numbers · no invented testimonials ·
no price · no roadmap dates. The docs' container/VM warning is quoted as the
agent's own documentation, not as our policy.

View File

@@ -0,0 +1,75 @@
# Rationale — "Should you let your coding agent run without asking permission?"
FRE-199 item 1, page 2 of the second pair. Drafted 2026-11-05 by CMO (internal
target Mon 2026-11-09, beaten by four days).
## The phrase
Primary: **"do you let your coding agent run without asking permission"**
(variants: "safe autonomous coding workflow", "approve every step or skip
permissions", "is dangerously-skip-permissions safe").
On the standing phrase list (`drafts/listening.md` §The phrase list: "do you
let claude code run without asking").
## Why this phrase won the slot
- **Recurrence with independence.** Four separate threads state the autonomy
question from four angles: the direct one (1xexhwf, 24 comments), the trust
asymmetry (1xk8imc, 43 comments), the safety menu (1xk277d), and the
remove-permissions ask (1xfe6eh — Desktop, recorded, not answered). The
cluster has run continuously since Scan 1.
- **Buying intent.** These are people configuring or rejecting autonomy right
now — the objection vocabulary came straight out of this cluster
("--dangerously-skip-permissions and hoping").
- **Answerable honestly, generically.** The mode ladder, the docs' own
container/VM warning, the enforcement note, and `PreToolUse` deny are all
documented; every claim traces to the agent's docs. No product needed.
- **Distinct from pair 1's permission page.** `agent-waiting-on-permission`
answers "why is it *blocked* and why didn't I know" — the visibility of a
single stuck prompt. This page answers "should I *grant* autonomy and how do
I bound it" — the mode ladder, rule precedence, sandboxing, and hooks as
enforcement. Shared vocabulary (modes, rules) exists because the docs are
one system; the question, the claims, and the advice do not overlap.
## Evidence trail
| Thread | Date | What it showed |
|---|---|---|
| r/ClaudeCode 1xexhwf — "Do you let Claude Code run without asking, or approve every step?" | 2026-10-25 | The direct question, 24 comments; the thread became a safety argument (VM, worktrees, empty-variable `rm -rf`) — that argument *is* the page's subject |
| r/ClaudeCode 1xk8imc — "Has anyone built a safe, autonomous coding workflow…?" | 2026-10-31 | "Comfortable having another model work on code. Considerably less comfortable giving an autonomous agent broad access to my development environment" — the trust asymmetry, 43 comments |
| r/ClaudeAI 1xk277d — "How often has a coding agent done something you had to undo?" | 2026-10-31 | "What you do to stay safe today: committing before every run? containers? `--dangerously-skip-permissions` and hoping?" — the operator's own menu |
| r/ClaudeCode 1xipsk0 — "~40 agents unattended for a month" | 2026-10-29 | "A run started from launchd or a background service can hit a prompt that no one will ever see" — the unattended failure path |
| r/ClaudeCode 1xkoz06 | 2026-10-31 | The undo-split practice (also cited on pair 1; real thread, described without handles) |
| r/ClaudeAI 1xfe6eh — "How can I remove Claude's need to ask me for permission…" | 2026-10-30 | Recorded, not used: Claude Desktop computer-use, not Code — a mention there would be noise. Shows the phrase's reach |
All rows live in `drafts/listening.md` (The-Nook local master line, single
canonical copy per FRE-748) with URLs and verbatim quotes.
## Gates before this page ships
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
Freno). Page moves `drafts/` → `pending/` → `approved/` on his read.
2. **CTO eyes on the agent-behaviour claims** — six doc-sourced claims
(permissions doc ×5, hooks reference ×1), all re-read 2026-11-05. Routed to
the CTO per the FRE-199 plan.
3. **Publish path:** `approved/` → a human places it on nook.freno.me.
Nothing posts itself.
## Ban compliance (FRE-199 + positioning.md rule 1)
- The Nook: not named. The notch, the island, the companion: not mentioned.
- Competitors: not named. Sandboxing and hooks are described as the agent's
own documented mechanisms, not as anyone's product.
- Claude Code is named only as the subject whose documented behaviour the page
explains, with its own docs as the source — same status as pair 1.
- No superlatives, no benchmark numbers, no invented testimonials, no price,
no roadmap dates. Anecdotes are real threads, described without handles.
## Cadence note
Second pair. Queue behind it: the fan-control phrase ("mac fan control
claude", standing phrase list), the invisible-permission variants
(dontallow/TCC cluster), and the notification-reliability thread (1xm6frp,
held back for overlap with done-signal). Above the three-candidate floor; two
a week holds.

View File

@@ -0,0 +1,86 @@
# Why is my coding agent sitting on a permission prompt?
> **Status: approved — sponsor fact-check passed 2026-11-05** (FRE-199 fact-check
> card, verdict: "Approve both — move to approved/"). Not published; a human
> places it on nook.freno.me from `approved/`.
> This page never names a product of ours and never names a competitor.
> Internal target: second page of the FRE-199 pair, Fri 2026-11-07.
> CTO review 2026-11-05: changes requested and applied — rule precedence
> corrected to deny / ask / allow (body previously read allow / ask / deny,
> contradicting the fact-check table and the source); pair-1 citations
> standardised to code.claude.com.
Because it tried to do something its permission settings don't allow, and the
default is to wait — indefinitely and quietly — for you. The prompt is not the
expensive part; the hour you spent not knowing it was waiting is. The way out is
not to approve faster, it's to make the prompts rare and the waiting visible.
## The mechanism
Claude Code's permission system decides, per tool call, whether to run, refuse,
or stop and ask. In its `default` mode, it prompts for permission on the first
use of each tool. Modes are set with `defaultMode` in the settings files, and a
session's rules come from deny / ask / allow lists evaluated in that order —
the first match wins.
You can see the whole rule set at any time with `/permissions`, which lists
every permission rule and the settings file each one came from. Rules can name
a specific use — `Bash(npm run build)` matches exactly that command — or a
wildcard, like `Bash(git log *)`.
So the prompt you came back to an hour later is not a malfunction. It is the
system working as configured, on a call your rules never covered — and the
"waiting" state being invisible is what turned ten seconds of approval into a
lost hour.
## What to check or do
1. **Read the prompt before automating it away.** It names the tool and the
input. The right response to "it keeps asking" is usually a rule, not a
wider mode.
2. **Allowlist the undoable, prompt on the irreversible.** Reads, tests, and
status commands are safe to allow — `git` is the undo for edits. Anything
that reaches another person or another system — network calls, deletes,
deploys — is worth keeping on ask. Rule syntax is in the permission docs;
`/permissions` shows what you already have.
3. **Consider `acceptEdits` before anything broader.** It auto-accepts file
edits and common filesystem commands inside the working directory. That is
usually the difference between ten prompts a session and two — without
handing over network or delete.
4. **Bound the blast radius instead of removing the gate.** If you do widen
what runs unattended, do it where a mistake is recoverable: a worktree per
task, commits pushed early, nothing irreversible in reach. Operators who run
agents unattended describe exactly this split — free hand on what git can
undo, confirmation on what it can't.
5. **Make the wait visible.** Claude Code fires a `Notification` hook when it
sends a notification, and a permission prompt is one — the payload carries a
`notification_type` of `permission_prompt`. A hook on that event, wired to a
desktop notification you'd see from another window, turns the silent wait
back into a ten-second approval.
One next step: run `/permissions` today and add allow rules for the three
commands you find yourself approving most. The prompts that remain will be the
ones that deserve a read.
---
## Fact-check table (not part of the page)
Every technical claim above, with its primary source. Claims about Claude
Code's behaviour come from its own documentation, re-read 2026-11-05.
| # | Claim in the page | Source |
|---|---|---|
| 1 | In `default` mode Claude Code "prompts for permission on first use of each tool" | Claude Code permissions doc — https://code.claude.com/docs/en/permissions (Permission modes table) |
| 2 | `defaultMode` in settings files sets the default mode for new sessions | same permissions doc (Permission modes section) |
| 3 | Rules are evaluated deny → ask → allow, first match wins | same permissions doc (Permission rule syntax) |
| 4 | `/permissions` lists all permission rules and the settings.json each comes from | same permissions doc (Manage permissions) |
| 5 | `Bash(npm run build)` matches the exact command; `Bash(git log *)` wildcard form | same permissions doc (Use specifiers for fine-grained control) |
| 6 | `acceptEdits` "automatically accepts file edits and common filesystem commands such as mkdir, touch, mv, cp for paths in the working directory" | same permissions doc (Permission modes table) |
| 7 | A `Notification` hook fires when Claude Code sends a notification; permission prompts carry `notification_type: "permission_prompt"` and a message like "Claude needs your permission" | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (Notification event) |
| 8 | Undo-split practice ("free hand on what git can undo, confirmation on what it can't") | r/ClaudeCode 1xkoz06 (2026-10-31, u/tomm3hshipp thread), commenter's described practice, logged in `drafts/listening.md` Scan 3 addendum. Described as one operator's approach, not a rule. |
| 9 | The 55-minutes-lost framing | r/ClaudeAI 1xhbc71 (2026-10-28): "coming back an hour later to find it's been sitting on a permission prompt for 55 minutes". Used as the problem's shape, not quoted as a person. |
**Ban compliance:** no product of ours named · no competitor named · no
superlatives · no benchmark numbers · no invented testimonials · no price · no
roadmap dates.

View File

@@ -0,0 +1,77 @@
# Rationale — "Why is my coding agent sitting on a permission prompt?"
FRE-199 item 1, page 2 of the first pair. Drafted 2026-11-05 by CMO.
## The phrase
Primary: **"claude code waiting for permission"** and the away-from-desk variant
("approvals when you're away from the computer"). First phrase on the standing
list (`drafts/listening.md` §The phrase list).
## Why this phrase won the slot
- **The strongest single thread in the backlog.** r/ClaudeAI 1xhbc71 (2026-10-28,
47 comments and moving) asks the buying question outright: kicked off a long
task, came back an hour later, agent had been sitting on a permission prompt
for 55 minutes. The thread's own answers (remote control, tmux+SSH,
skip-permissions, allowlists) prove demand; none of them makes the wait
visible at the machine — that is the honest gap this page answers without
naming anyone.
- **The week's objection vocabulary.** Scans 1–9 keep returning "the model keeps
handing control back" and "stops and waits for me" — permission friction is
the category's core complaint, not a niche one.
- **Recurring independent accounts.** The invisible-permission theme has three
independent accounts in one week (1xksutq — gh used without a visible prompt;
1xkejsp's Linux panel; the scheduled-tasks silent-stall finding), so the
search demand is not one loud thread.
- **Wedge-aligned.** This is the literal wedge sentence's territory — "an agent
blocked on a permission prompt is the most expensive kind of idle" — answered
with the agent's own documented mechanism.
## Deliberate scoping
This page owns the **blocked-half** of the approval problem (why prompts happen,
how to make them rare, how to make the wait visible at the machine). The
**away-half** (controlling a remote agent from elsewhere) is a different phrase,
a different page, and one where the honest answer is dominated by the agent
vendor's own remote feature — the page deliberately does not go there
(`drafts/listening.md` Scan 1 category intel #1).
## Evidence trail
| Thread | Date | What it showed |
|---|---|---|
| r/ClaudeAI 1xhbc71 — "How do you handle Claude Code approvals when you're away from your computer?" | 2026-10-28 | 55-minute wait on a permission prompt; 47 comments of demand; "what's still annoying about your setup?" |
| r/ClaudeCode 1xkoz06 — "How permissive are you with AI and for how long?" | 2026-10-31 | The undo-split: "file edits, tests and branches get a free hand, since git is the undo. Anything that reaches another person … stays on confirm" |
| r/ClaudeCode 1xksutq — gh used without visible prompt | 2026-11-01 | Third independent invisible-permission account of the week |
| r/ClaudeCode 1xkejsp — Linux panel app | 2026-10-31 | Per-session "Needs You" differentiation shipping elsewhere; the need is real and cross-platform |
All rows live in `drafts/listening.md` with URLs and verbatim quotes.
## Gates before this page ships
1. **Sponsor fact-check** of the claims table in `copy.md` (owner: Michael
Freno); page moves `pending/` → `approved/` on his read.
2. **CTO eyes on the agent-behaviour claims** — all eight doc-sourced claims
re-read 2026-11-05; the FRE-199 plan routes anything asserted about an
agent's behaviour to the CTO.
3. **Publish path:** `pending/` → `approved/` → a human places it on
nook.freno.me (blog route). Nothing posts itself.
## Ban compliance (FRE-199 + positioning.md rule 1)
- The Nook: not named; notch/island/companion not mentioned.
- Competitors: not named — including the agent vendor's remote feature, which
the page never needs to mention because it stays on the at-the-machine half.
- Claude Code is named only as the subject whose documented permission system
the page explains, with its own docs as the source — same status the
listening phrase list already gives it.
- No superlatives, no benchmark numbers, no invented testimonials, no price,
no roadmap dates.
## Cadence note
Second page of the first pair. Queue behind it: multi-agent tracking ("How do
you keep track of multiple coding agents running in parallel?", 1xm6frp,
2026-11-03) and the invisible-permission cluster — the queue holds above the
three-candidate floor, so the two-a-week cadence holds.

View File

@@ -0,0 +1 @@
# briefs

View File

@@ -0,0 +1,91 @@
# Registry wave 2 — brief and copy (FRE-199 item 3)
**Owner:** CMO prepares · sponsor submits · **Copy ready 2026-11-05** (target was
Fri 2026-11-13) · **Submit:** week of 2026-11-16, after the wave-1 readback.
**Status:** `briefs/` — nothing here has been posted; humans hold every account;
no agent authenticates, posts or sends.
## What wave 2 is, and what it waits for
Wave 1 is FRE-207's packet (`The-Nook` worktree `drafts/directory-submissions.md`
+ `drafts/click-list.md`): 8 targets, CTO claim verdict applied 2026-11-01
(C1–C5 signed, C7 refused → relay sentence), sponsor-owned inputs G2 (price
line), G3 (`@freno.me` mailbox), G4 (screenshots). **Wave-2 submission waits on
the wave-1 readback** — what actually got submitted, what got listed, what was
rejected and why — so targets can be re-ranked on evidence instead of guesswork.
The readback lives on the FRE-207 issue; this brief is ready either way.
**Copy rule inherited from wave 1:** paste the wave-1 blocks, do not improvise.
Only claims signed by the CTO verdict ship:
- C1 — works with 18 coding agents (compatibility fact, not a competitor mention).
- C2 — answer an agent's approval from your Mac, iPhone or Watch.
- C3 — sessions keep arriving while the Mac app is closed (widgets; fleet status only).
- C4 — macOS 14.2+, universal.
- C5 — iPhone/iPad and Apple Watch companions are **TestFlight beta**; no App Store link anywhere.
- C7 — "no cloud service of its own" is refused; block D ships the relay sentence.
- **Price line (G2):** "Free 14-day trial" only — no number — until CoS + sponsor
clear the "$10 one-time licence" line for public listings (FRE-204 holds $10 in-app;
the listing text is a separate publish decision).
- Banned: superlatives, user counts, testimonials, competitor names.
## Targets — fetched live 2026-11-05, marked per the wave-1 legend
| # | Target | What it is | How it ships | Fit | Verified |
|---|---|---|---|---|---|
| W2-1 | `iCHAIT/awesome-macOS` (19.4k ★) | Curated macOS app list; PR-based | PR adding one line to the relevant section from `contributing.md`; copy: block A + block B compressed | High — category fit, persistent link | ✅ repo + contributing rules read 2026-11-05 |
| W2-2 | 1000.tools | Curated tool directory, self-serve categories (AI, developer tools, productivity) | Category submission form; copy: block A + B | Medium-high | ✅ fetched 2026-11-05 (categories listed on page) |
| W2-3 | uneed.best | Daily product launch site | Launch submission (account needed, held by a human) | Medium — launch-format, needs assets | ✅ fetched 2026-11-05 |
| W2-4 | devhunt.org | Daily dev-tool launches | Launch submission | Medium | ✅ fetched 2026-11-05 |
| W2-5 | Product Hunt | Launch site | Maker/hunter flow, on-screen (403 to plain fetch — read live before filing) | Low-medium this wave; big-audience launch usually wants FRE-203's launch-day coordination | ⚠️ bot-walled; fields read on screen |
Re-rank after the wave-1 readback: if directory listings (wave-1 §1/§3–5)
produced traffic or referrers, wave 2 leads with W2-1/W2-2; if nothing was
submittable, wave 2 waits until G3/G4 clear rather than submitting half-copy.
## Copy — paste-ready, inheriting the signed claims only
**One-liner (≤60):** `Your coding agents, at the edge of the screen` (43 chars — wave-1 block A).
**Short description (~250 chars):** wave-1 block B verbatim; if a field forces a
price, "Free 14-day trial" and no number (G2).
**awesome-macOS PR line (one row, section: menu-bar / status-bar apps or the
section the maintainers route notch apps to — read the README section headers
before filing):**
```
- [The Nook](https://nook.freno.me/) - Puts every coding-agent session at the
edge of your screen and lets you answer approvals from your Mac, iPhone or
Watch. Works with 18 coding agents. [![Open-Source Software][OSS Icon]] — not
open source; omit the badge row, keep the description.
```
(The badge syntax is copied from the list's own rows; The Nook is not open
source, so the OSS badge is **omitted** — the line above shows the shape only.
No TestFlight mention needed in a one-row listing; add "Companions for iPhone
and Apple Watch (TestFlight beta)" only if the PR reviewer asks for platform
detail — C5 wording.)
**Launch-site fields (W2-3/W2-4):** wave-1 blocks A, B, C (widgets — fleet
status only), D (relay sentence, not C7's refused wording). Gallery needs G4's
three real screenshots.
## Gates before anything here is submitted
| Gate | Owner | State |
|---|---|---|
| Wave-1 readback recorded (what submitted/listed/rejected) | sponsor → FRE-207 issue, carried to FRE-199 | owed before submission week |
| G2 price line for public listings | CoS + sponsor | pending (copy ships without a number either way) |
| G3 `hello@freno.me` mailbox | sponsor | pending |
| G4 three real screenshots + logo PNG | sponsor or CTO | pending — blocks every upload field |
| Claim sign-off | CTO | **cleared 2026-10-31** for C1–C5; C7 ships as the relay sentence |
## Provenance
- Targets fetched 2026-11-05: uneed.best, devhunt.org, 1000.tools (HTTP 200,
content read); `iCHAIT/awesome-macOS` via GitHub API (19,441 stars,
contributing.md linked from README); producthunt.com returns 403 to a plain
fetch — marked ⚠️, read on screen before filing.
- All claims inherited from FRE-207's CTO verdict of 2026-10-31 — no new
claims introduced by this brief.

4
marketing/calendar.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$comment": "Scheduled pushes: date, channel, status. Statuses: draft | pending | approved | posted. Move nothing to posted without the artifact living in approved/ first — see positioning.md rule 5.",
"pushes": []
}

View File

@@ -0,0 +1 @@
# drafts

View File

@@ -0,0 +1,96 @@
# Why does your coding agent keep stopping to ask you questions?
> **Status: draft — awaiting sponsor fact-check** (FRE-199 pair 3, internal target
> Thu 2026-11-12). Not publishable from `drafts/`.
> This page never names a product of ours and never names a competitor.
Most of the stopping is not the model being chatty — it is the permission system
doing what it was told. In the default mode, the agent prompts before the first
use of each tool; separately, its turn ends every time it finishes responding,
and ending a turn with a question is finishing a turn. You cannot and should not
eliminate every ask — but you can decide, once, which asks deserve an
interruption and let the routine ones pass without you.
## Where the asks come from
**Permission gates.** In the default mode the agent "prompts for permission on
first use of each tool" — so every new tool, and often every new command shape,
costs one prompt. The configuration decides who answers:
- **`acceptEdits`** automatically accepts file edits and common filesystem
commands (like `mkdir`, `touch`, `mv`, `cp`) for paths in your working
directory — the single biggest ask category for most coding sessions, gone.
- **Permission rules** do the fine work: `deny` rules block a tool outright,
`allow` rules pass it silently, `ask` rules force the prompt. Order matters:
rules are evaluated deny, then ask, then allow — the first match wins, and an
allow rule cannot carve an exception out of a deny.
- **Modes set the floor.** `defaultMode` in your settings picks where every new
session starts. At the far end, `bypassPermissions` skips prompts entirely —
the docs' own warning is that it belongs only in isolated environments like
containers or VMs.
One fact worth internalising: permission rules are enforced by the coding
agent's harness, not by the model. A line in your prompt asking it to "not ask"
changes nothing about what it is allowed to do — the configuration is the only
lever.
**Turn ends.** A `Stop` hook fires when the agent finishes responding — every
turn, including the turns that end in a question. From the terminal's side of
the glass, "it finished", "it's asking me something" and "it's waiting on a
permission prompt" are the same silence. That is why the asking *feels*
endless: each ask costs a context switch, and nothing distinguishes the asks
from the completions.
Practitioners describe the pattern in their own words. A developer who measured
why their sessions kept stopping found that the agent "just kept handing
control back" even with a permissive mode already on — the model's own habit of
ending turns to stay in the loop, on top of the permission asks. An operator
running a team of agents with per-agent autonomy levels put the other side:
"if every decision routes through you, you're the bottleneck agents were meant
to eliminate."
## What to check or do
1. **Sort the asks by kind before touching anything.** File edits in your
working directory: `acceptEdits` removes them wholesale. The same safe
command every session: an `allow` rule. The commands that must never run: a
`deny` rule. What is left is the set of questions only you can answer.
2. **Write the rules in the right order.** Deny, then ask, then allow; first
match wins. A broad deny stays broad — narrowing an allow elsewhere does not
soften it.
3. **Keep the genuinely ambiguous asks.** A mode that asks less is not a mode
that asks smarter. `bypassPermissions` in a throwaway container is a
documented pattern for unattended runs; using it on your daily machine is
the docs' own caution, not a recommendation.
4. **Make every stop visible.** Because every stop ends a turn, wire the
turn-end and notification moments (the `Stop` and `Notification` hooks) so
an ask reaches you where you actually are — and a permission wait is
distinguishable from a finished job.
One next step: run one session with the asks tallied by kind — edits, known
commands, everything else. The first two usually turn out to be rules you
hadn't written yet; the third is the work.
---
## Fact-check table (not part of the page)
Every technical claim above, with its primary source. Claims about the
permission system and hooks were verified against the live docs on 2026-11-05.
| # | Claim in the page | Source |
|---|---|---|
| 1 | Default mode "prompts for permission on first use of each tool" (labeled Manual; `manual` alias) | Claude Code permissions doc — https://code.claude.com/docs/en/permissions (permission modes table; fetched 2026-11-05) |
| 2 | `acceptEdits` "automatically accepts file edits and common filesystem commands such as mkdir, touch, mv, and cp for paths in the working directory or additionalDirectories" | same page (permission modes table) |
| 3 | Rules are evaluated in order deny, then ask, then allow; "the first match in that order determines the outcome"; "an allow rule can't carve an exception out of a deny rule" | same page (rule precedence section) |
| 4 | `bypassPermissions` "skips permission prompts"; docs' caution: "Only use this mode in isolated environments like containers or VMs where Claude Code can't cause damage" | same page (permission modes table) |
| 5 | `defaultMode` in settings files sets the default permission mode for new sessions | same page ("To change the default permission mode for new sessions, set defaultMode in your settings files") |
| 6 | "Permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or CLAUDE.md shape what Claude tries to do, but they don't change what Claude Code allows." | same page (verbatim) |
| 7 | `Stop` fires when the agent finishes responding; hook cadence is per turn | Claude Code hooks reference — https://code.claude.com/docs/en/hooks (verified 2026-11-05 in FRE-199 round 1; unchanged) |
| 8 | Anecdote: "it just kept handing control back"; sessions kept stopping with a permissive mode on | r/ClaudeCode thread 1xlfqmo ("I measured why my Claude Code sessions kept stopping…", 2026-11-01, u/belovoj), logged in `drafts/listening.md` Scan 5 #1. Quoted as one developer's described experience; the thread's own measurement figures are deliberately **not** cited. |
| 9 | Anecdote: "if every decision routes through you, you're the bottleneck agents were meant to eliminate"; per-agent autonomy levels 0–5 | r/ClaudeCode thread 1xk0ghj ("multiple agents working on multiple projects", 2026-10-31, quote by u/iceydee), logged in `drafts/listening.md` Scan 4 #2. One operator's described practice. |
**Ban compliance:** no product of ours named · no competitor named · no
superlatives · no benchmark numbers (the 1xlfqmo poster's measurement figures
are deliberately not cited) · no invented testimonials (anecdotes are real
threads, described without handles) · no price · no roadmap dates.

View File

@@ -0,0 +1,52 @@
# Rationale — "Why does your coding agent keep stopping to ask you questions?"
FRE-199 item 1, page 2 of the third pair. Drafted 2026-11-06 by CMO (internal
target Thu 2026-11-12 — six days early).
## The phrase
Primary: **"why does my coding agent keep stopping to ask me questions"**
(variants: "agent keeps handing control back", "claude code keeps asking for
permission"). The H1 carries the generic form; "handing control back" is the
backlog's own added phrase (Scan 5's phrase-list finding).
## Why this phrase won the slot
- **Backlog evidence.** r/ClaudeCode 1xlfqmo (2026-11-01, Scan 5 #1): "Auto mode
was already on, so permissions weren't the issue… it just kept handing
control back"; "the model was forcably trying to keep me in the loop." The
phrase-list finding from the same scan added "handing control back" as a
standing query. Corroborated by 1xlxw0q (turns ending with "nothing yet") and
the autonomy-ladder operator (Scan 4 #2, thread 1xk0ghj: "if every decision
routes through you, you're the bottleneck agents were meant to eliminate").
- **Shortlist position (c)** in the plan's pair-3 shortlist, recorded at
revision 5 — the page extends the done-signal cluster (pair 1) with the
handback half. The distinction is kept deliberately: *agent-done-signal*
owns "how do I know it's done"; this page owns "why does it keep stopping on
me". Different question, different searcher.
- **Answerable honestly from verified sources only.** Every mechanism claim
re-reads to the permissions doc and hooks reference — the same pages and the
same claims the CTO verified in FRE-199 rounds 1–2 (mode ladder, precedence,
acceptEdits scope, enforced-by-harness sentence, Stop cadence). Nothing new
is asserted about agent behaviour.
## What the page deliberately does not do
- **Does not cite the 1xlfqmo poster's measurement figures** (the 28-of-73
sample is the poster's own 18-day data, and the backlog row carries an
explicit do-not-cite flag). The anecdote is quoted as experience, not
measurement, and the fact-check table says so.
- Does not promise the asks can be eliminated. The honest answer is that the
asking is partly the permission system working as configured and partly the
agent ending turns to stay in the loop — the page sorts which is which.
- Does not touch environment contention (page 1 of this pair owns that half).
## Evidence trail
| Thread | Date | What it showed |
|---|---|---|
| r/ClaudeCode 1xlfqmo — "I measured why my Claude Code sessions kept stopping" | 2026-11-01 | "It just kept handing control back" with a permissive mode on; "forcibly trying to keep me in the loop" — the model-behaviour half |
| r/ClaudeCode 1xk0ghj — "multiple agents working on multiple projects" (quote by u/iceydee) | 2026-10-31 | "If every decision routes through you, you're the bottleneck agents were meant to eliminate"; per-agent autonomy levels 0–5 — the configuration half |
| r/ClaudeAI 1xlxw0q — "2,300 hours waiting" | 2026-11-02 | Turns ending in a one-word answer reload context each time — the per-ask cost, corroborating |
All rows live in `drafts/listening.md` (The-Nook local master line).

View File

@@ -0,0 +1,72 @@
# How do you manage worktrees when your agents end up waiting on each other?
> **Status: draft — awaiting sponsor fact-check** (FRE-199 pair 3, internal target
> Thu 2026-11-12). Not publishable from `drafts/`.
> This page never names a product of ours and never names a competitor.
Usually, the worktrees are not what your agents are waiting on — the environment
they share is. Two agents can sit in two worktrees of the same repo and still
queue up behind one dev server, one test database, one `docker compose` stack.
The fix that scales is to multiply the environment per worktree, not to
orchestrate the waiting.
## Why agents queue when the code doesn't
Git supports parallel work out of the box: `git worktree add <path> <branch>`
creates a second working directory linked to the same repository — shared
history, but its own HEAD, its own index, its own checked-out branch. Git even
enforces the ownership half for you: it refuses to check out a branch into a
second worktree while the first one holds it.
What git does not duplicate is everything the repo only *points at*: the dev
server bound to port 3000, the database named after the project directory, the
compose stack whose project name defaults to the folder you launched it from.
Two agents in two worktrees that both bring up the same stack are two agents
contending for one environment — and the queue looks like a worktree problem
when it is an environment problem.
Practitioners who hit this converge on the same verdict. One, in a thread
asking this exact question: the queue "comes from having one test env, so I'd
multiply the env instead of orchestrating the waiting."
## What to check or do
1. **Give every worktree its own copy of the stack.** Docker Compose exists for
exactly this: it uses a *project name* to isolate environments from each
other, and names "multiple copies of a single environment, useful for
running stable copies for each feature branch" as a use case. Set a unique
one per worktree — the `-p` flag has top precedence, or use `name:` in the
compose file — and the port and container-name collisions disappear.
2. **Anything that cannot be multiplied, serialise on purpose.** One migration
runner, one deploy target, one flaky hardware-in-the-loop device: put it
behind a single serial step rather than letting two agents race it. A queue
you chose is a queue you can reason about.
3. **One branch, one worktree.** Git already refuses to check out the same
branch in two worktrees; keep it that way. Two agents on one branch is a
merge conflict waiting for both of them to finish.
4. **Remember a fresh worktree starts unconfigured.** Env files, generated
config and installed dependencies live in the worktree, not the repo — a new
worktree is a new environment to set up, which is exactly why the per-worktree
copy should be scripted, not hand-arranged.
One next step: find the resource your agents contend for most often — usually
the test stack — and give it a per-worktree project name. The waiting usually
resolves before any queueing tool is needed.
---
## Fact-check table (not part of the page)
Every technical claim above, with its primary source. Claims about git and
Docker Compose were verified against the live docs on 2026-11-05.
| # | Claim in the page | Source |
|---|---|---|
| 1 | `git worktree add <path> [<commit-ish>]` creates a worktree "linked to the current repository, sharing everything except per-worktree files such as HEAD, index, etc." | git worktree docs — https://git-scm.com/docs/git-worktree (COMMANDS › add; fetched 2026-11-05) |
| 2 | `add` "refuses to create a new worktree when <commit-ish> is a branch name and is already checked out by another worktree" | same page (OPTIONS › `--force`) |
| 3 | Compose "uses a project name to isolate environments from each other"; "multiple copies of a single environment, useful for running stable copies for each feature branch" is a named use case; precedence: `-p` flag > `COMPOSE_PROJECT_NAME` > `name:` attribute > directory basename | Docker Compose docs, "Specify a project name" — https://docs.docker.com/compose/how-tos/project-name/ (fetched 2026-11-05) |
| 4 | Anecdote: "The queue comes from having one test env, so I'd multiply the env instead of orchestrating the waiting" | r/ClaudeCode thread 1xlz2bs ("How do you manage worktrees when your agents end up waiting on each other?", 2026-11-02, u/Roland_culait / u/First-Painter7431), logged in `drafts/listening.md` Scan 6 #4. Quoted as one commenter's described practice, not a measurement. |
**Ban compliance:** no product of ours named · no competitor named · no
superlatives · no benchmark numbers · no invented testimonials (the anecdote is
a real thread, described without handles) · no price · no roadmap dates.

View File

@@ -0,0 +1,46 @@
# Rationale — "How do you manage worktrees when your agents end up waiting on each other?"
FRE-199 item 1, page 1 of the third pair. Drafted 2026-11-06 by CMO (internal
target Thu 2026-11-12 — six days early).
## The phrase
Primary: **"how do you manage worktrees when your agents end up waiting on each
other"** — carried verbatim as the H1, because it is already a complete search
phrase with the exact buying intent.
## Why this phrase won the slot
- **Backlog evidence.** r/ClaudeCode 1xlz2bs (2026-11-02, ~15 h old at scan time,
Scan 6 #4). The OP asked the question in its final words; the thread answered
itself (env per worktree, `docker compose -p`, preview deploys, one serial
step on main) — which is exactly what makes it page material: the demand is
proven, the answer is proven, and the answer is generic.
- **Shortlist position (a)** in the plan's pair-3 shortlist, recorded at
revision 5. Scan 9 (2026-11-05) produced no new queue item, so the
shortlist stands; selection finalised against that fresh scan as planned.
- **Answerable honestly and generically.** Both mechanisms are documented
primary sources (git worktree docs; Docker Compose project-name docs), both
fetched and read 2026-11-05. No agent-behaviour claim is needed — this page
is the lightest CTO-review lift of the shortlist.
- **Wedge-adjacent without a mention.** "Waiting" is the cost our wedge names
("an agent blocked is the most expensive kind of idle"); this page owns the
agent-on-agent half of waiting, which no existing page covers.
## What the page deliberately does not do
- Does not cite the thread's own tooling (Orca, oyren.ai) or any workflow
product — category nouns only.
- Does not invent a measurement. The one quoted line is a commenter's described
practice, logged in the backlog with a do-not-interpret flag.
- Does not touch the approval surface (page 2 of this pair owns the
attention/handback half; this page is environment contention only).
## Evidence trail
| Thread | Date | What it showed |
|---|---|---|
| r/ClaudeCode 1xlz2bs — "How do you manage worktrees when your agents end up waiting on each other?" | 2026-11-02 | Exact phrase in the wild; self-answered with env-multiplication advice; "multiply the env instead of orchestrating the waiting" |
| Watched-table corroboration (Scans 5–9) | 2026-11-02 → 11-05 | Repobs ("which sessions were waiting for me"), Orca (worktree orchestration daily driver), Worktrunk, Conductor.build, Parallel Code — a shelf of builders around the same phrase; the demand is broad |
All rows live in `drafts/listening.md` (The-Nook local master line).

View File

@@ -0,0 +1,96 @@
# Newsletter pitch — post-mortem shape (narrowed; no conversion figure)
> **Status: draft — narrowed 2026-11-06 per the CTO's reopening verdict on
> FRE-199.** The "X% trial→paid" half of the shape **cannot ship**: the
> FRE-202 snapshot (window 2026-10-22 → 10-29) reads trial started 2, purchase
> 0, activation 0, with 291 of 291 analytics rows predating the FRE-366
> instrumentation — a trial→paid ratio from that cohort is not a metric. The
> ratio comes from FRE-197's mature Day 28–35 cohort read when it lands.
> **No figure in this file is a measurement.** Send gates at the bottom.
## Audience & channel
Editors of free dev-tool newsletters (the ones that run post-mortems and
"what we learned" pieces). Direct email, one editor at a time, personalised.
No paid placements at any budget without a CoS + sponsor decision.
## The claim the pitch goes out with
A post-mortem pitch, not a product pitch: *here is what shipping a Mac utility
alone actually broke, and what we changed because of it.* The product is the
setting; the breakage and the fix are the story. The story stands on its own
with zero funnel numbers — if no verified number exists on send day, the pitch
ships without one rather than with a placeholder.
## The pitch (shape — names and dates personalised; nothing else is a placeholder)
**Subject:** What shipping a Mac utility alone broke (post-mortem)
Hi <editor>,
We build The Nook, a $10 Mac utility that surfaces coding-agent approvals at
the notch. I'm not pitching the app — I'm offering a post-mortem your readers
usually only get from funded startups, because shipping alone broke things in
ways bigger teams don't hit:
- **The silent update.** In October we shipped a release whose appcast published
cleanly — while every delta it named 404'd, so users on older builds were
being offered updates that didn't exist. The publish path deleted files
before the feed that named them was live. Nothing alerted; the feed just
lied quietly. The fix — upload the feed's enclosures, verify every enclosure
URL, copy the appcast up, and only *then* prune — is now ordered so the
deletion can't run first.
- **The green suite.** Weeks earlier, a regression in our public relay shipped
behind a passing test run — the suite that would have caught it existed but
nothing invoked it. A green checkmark that covers nothing looks identical to
one that covers everything. The suite is now wired into the standard test
entry point, and the relay's deploy patcher is tested alongside it.
- **What we're not going to quote you.** A growth number, right now, would be
theatre: our funnel is young and a conversion ratio from an immature cohort
is not a measurement. If we attach a count, it will be activations — one per
activated install, from our own relay store — and we will say exactly that,
because download counts (a different number, from a different store) flatter
nothing and confuse everyone. The receipts that matter here are the failure
modes above, not a traction line.
The angle for your readers: solo distribution is a chain of quiet failure
modes, each one invisible until it costs a user. We kept the receipts.
Happy to send the full write-up, screenshots of the broken feed, and the exact
publish-order and test-wiring changes.
— <name>, FrenoCorp
## Where a number could come from, and what disqualifies it
- **Trial→paid ratio: cannot ship today.** FRE-202's snapshot shows a cohort of
2 trials, 0 purchases, pre-instrumentation rows. The ratio waits for
FRE-197's mature Day 28–35 read. If it lands before send day and the CTO
confirms the query, the ratio may be added — and it must say it is per
*install* (install cap 3), not per person.
- **Activations: the only count eligible to ship.** One row per activated
install (`installs`, `created_at`) in the relay store; CTO confirms the query
before any send. The pitch names it as activations, never as downloads.
- **Downloads (S3 side) are never quoted.** Different store, automation-heavy,
and the pitch that says "downloads" when it means activations burns the
credibility the post-mortem is built on.
- No number is estimated to fill any gap, ever.
## Gates before anything is sent
1. Any count quoted is activations, from a CTO-confirmed query — owner: CTO.
2. The trial→paid ratio ships only from FRE-197's mature cohort read with CTO
confirmation; otherwise the "what we're not going to quote you" paragraph
stands as written — owner: CTO.
3. Both breakage paragraphs are fact-checked against the engineering record
(FRE-542 publish-order incident; the NOOK-815 uninvited-suite relay
regression) before send — owner: CMO, evidence to CTO for verdict.
4. Sponsor approves the send and holds the sending account (humans hold every
account) — owner: Michael Freno.
## Target dates (internal)
- Draft with placeholders: **done 2026-11-05**; narrowed to the no-figure shape
**2026-11-06** (CTO direction).
- Send: after gates 1–4 — with the no-figure shape, the only hard input left is
the CTO's query confirmation and the sponsor's send.

View File

@@ -0,0 +1,73 @@
# Rationale — newsletter post-mortem pitch (shape)
FRE-199 item 2. Drafted 2026-11-05 by CMO (plan date was 2026-11-06; FRE-201
landed today, which was the only gate on drafting the shape).
## Why draft now
The Chief of Staff's sequencing had the pitch drafted when FRE-201 landed. It
landed 2026-11-05 (row closed `done` 18:45Z; the daily scan routine
`2553b201` has been producing the backlog since 2026-10-30). The figures were
never gated on FRE-201 — only the shape was — so the shape ships now and the
figures stay placeholders behind their own gate.
## Why a post-mortem and not a product pitch
The issue text's own thesis: "1,900 downloads, 6% trial→paid, here's what
broke" outperforms a product pitch. Editors of free dev-tool newsletters run
post-mortems; a solo shop shipping a Mac utility and keeping receipts is a
story they can run without us asking for a review.
## The breakage is real, not invented
The lead paragraph is the 0.13.4 delta incident (FRE-542): the appcast stayed
at 0.13.4 while its five delta files 404'd — the publish path pruned before
the feed was up. The fix (upload enclosures, verify URLs, copy the appcast,
then prune) is in `scripts/upload-release.sh` and is engineering-verified by
the FRE-542 record.
**Second breakage, chosen 2026-11-06 (CTO direction):** the relay regression
that shipped behind a passing suite (NOOK-815) — the suite that would have
cought it existed but nothing invoked it; the fix wires it into the standard
test entry point. It was chosen over the changelog-blanking incident because
it is a complete story with no number needed and no overlap with the lead
(both remaining candidates were publish-path incidents; one story about the
publish path is enough). The placeholder is gone: no paragraph in the pitch
waits on a figure any more.
## Narrowed 2026-11-06 — what the numbers can and cannot support
The CTO's reopening verdict on FRE-199 confirmed **no figure for the pitch
today**: FRE-202's snapshot (trial started 2, purchase 0, activation 0; 291 of
291 rows predate the FRE-366 instrumentation) cannot yield a trial→paid ratio —
that waits for FRE-197's mature Day 28–35 cohort read. So the pitch narrowed:
- the "X% trial→paid" placeholder is deleted, not held;
- if any count ships, it is **activations** (relay store, one row per activated
install), named as activations — downloads (S3 side) are never quoted;
- a new paragraph — "what we're not going to quote you" — turns the missing
number into part of the post-mortem's credibility instead of a gap;
- with the no-figure shape, the only inputs left before send are the CTO's
query confirmation (if a count is attached) and the sponsor's send.
## Hard rules honoured
- No figure in the file is a measurement; nothing is estimated to fill a gap.
- No conversion figure ships — the ratio is FRE-197's, gated and CTO-confirmed.
- Any count that ships is activations, named as activations; downloads are
never quoted.
- The two caveats (per-install conversion with install cap 3; downloads ≠
activations) are written to travel inside the pitch, not a footnote.
- Paid placements: out, per the issue text and the plan (TLDR ≈ $3k, no
verifiable CPC). No placement is bought without CoS + sponsor.
- Price appears once ($10, what the app actually charges) to identify the
product — no discount, no countdown, no price-change copy (FRE-204 holds).
## Gates before send
1. Any count quoted is activations, from a CTO-confirmed query — CTO.
2. Trial→paid ratio ships only from FRE-197's mature read, CTO-confirmed;
otherwise the no-figure paragraph stands — CTO.
3. Both breakage paragraphs fact-checked against the engineering record
(FRE-542; NOOK-815) — CMO drafts, CTO verifies.
4. Sponsor approves the send; a human holds the account and sends — sponsor.

View File

@@ -0,0 +1,79 @@
# Remote SSH copy — HELD, do not publish
**Status:** removed from the `freno-dev` source on 2026-10-29 in the FRE-323
correction, and **not yet off the live site** — the deploy is blocked on a write
credential to `mikefreno/freno-dev` (card `82d1e1b7`). `nook.freno.me` still
serves every sentence below until that push or a Vercel deploy lands.
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
verifies that flow, and a recording shows a remote agent joining the island.
Until then this file is the only home for these sentences.
## What was removed
### 1. Landing collage tile
Source: `src/components/nook/FeatureCollage.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Agents on other machines
body: Run agents on Linux boxes and SSH servers — one click installs the
nook-hook, and their sessions join the same island.
```
The mock attached to it showed two session rows whose agent chips named
`build-box` and `gpu-rig`.
### 2. Landing feature breakdown section
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Any SSH box, one click away
body: Pick a host from your ssh config or your tailnet and click once — the
nook-hook installs itself and its agents appear beside your local ones,
indistinguishable.
bullets:
- One click: connect, push the hook, configure agents, check it can reach this Mac
- Hosts mirror this Mac's installed integrations automatically
- Remote sessions are ordinary rows; the agent chip names the machine
```
The section's demo animated a `gpu-rig` host moving from "Not set up" through
`connecting` → `pushing the hook binary` → `configuring agents` → `checking the
host can reach this Mac` → `Live`, then showed a remote session row.
### 3. Approvals bullet
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
- Works across every hooked agent, local or remote
```
### 4. Feature chip
Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
```
One-click SSH setup
```
## What replaced it
Nothing. The Remote tile and section were removed rather than replaced; the
existing Approvals cell already carries the verified local claim:
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
## Restore checklist
1. FRE-320/FRE-205 have shipped the one-click install and removed the
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
3. A recording exists showing a remote agent joining the island.
4. CMO restores the copy above, then owns the launch plan for it — with an
owner and date per item — before anything goes back on the site.

View File

@@ -0,0 +1,30 @@
# Why the remote claim is parked
The site was selling a setup path a buyer does not get.
FRE-320 measured today's actual path (2026-10-29): the one-click flow is
app-UI-only, needs a hand-edited `~/.ssh/config` for any host not already in ssh
config or on the tailnet, needs a one-time `ssh-copy-id` because the app runs ssh
with `BatchMode=yes`, and has no manual fallback for minting the per-host
credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
unverified and demo-gated. The CTO named it the largest launch risk because it
was already in front of buyers.
So the `freno-dev` source now says only what the product can back today: local
agents, the card, and the notch. That correction is committed locally and is
**not deployed** — the live page still carries the remote sentences until the
push in FRE-323 card `82d1e1b7` lands. The remote story is not deleted — it is
parked here with the FRE-320/FRE-205 work so it can return the day the one-click
install ships and is recorded.
## What has to be true before it returns
| Requirement | Owner | Date |
|---|---|---|
| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships |
| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves |
| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore |
| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true |
Nothing in this folder is publishable. `marketing/approved/` remains the only
place a human publishes from.

15
marketing/listening.md Normal file
View File

@@ -0,0 +1,15 @@
# Listening — buying-intent threads
Agent-found threads where someone is describing a problem The Nook solves, with a
drafted helpful-first reply. Replies are drafted here, reviewed, and posted **by a
human** with a single disclosed mention — helpful first, product second.
Rules:
- Real threads only — no invented scenarios.
- The drafted reply must stand alone as a helpful answer if the mention is removed.
- One disclosed mention per reply, maximum.
- Never post from this file; replies move through `approved/`.
| Date | Source | Thread | Problem signal | Drafted reply |
|---|---|---|---|---|

View File

@@ -0,0 +1,43 @@
# Weekly funnel snapshot
Window: **2026-10-22 18:05:00 → 2026-10-29 18:05:00 UTC** (7 days). Previous: 2026-10-15 18:05:00 → 2026-10-22 18:05:00 UTC.
Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's.
## Funnel
| Stage | This week | Last week | Change |
|---|---:|---:|---:|
| visit (nook.freno.me page views) | 0 | 0 | 0 |
| download click | 0 | 0 | 0 |
| DMG served | 0 | 0 | 0 |
| trial started | 2 | 0 | +2 |
| checkout started (Stripe session) | 0 | 0 | 0 |
| purchase (licence issued) | 0 | 0 | 0 |
| activation | 0 | 0 | 0 |
## Campaigns
Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.topCampaigns` returns.
| Source | Medium | Campaign | Rows |
|---|---|---|---:|
| x | — | — | 1 |
1 of 291 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
## The one thing that moved
**trial started: 0 → 2 (+2).**
## Before acting on these numbers
- **291 of 291 rows in the window predate the FRE-366 instrumentation.** They carry no host, and their recorded address is the production proxy's loopback (127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. They are excluded from every host-scoped stage above (visits, and the download click/served split), and "unique visitors" over them still collapses to one host. Rows recorded since carry the site they addressed and the visitor's address truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so "unique visitors" counts networks, not machines.
- **Download rows are automation-heavy.** Of this window's 137 download rows, 0 are clicks and 0 are served files; the remaining 137 predate the click/served stage label and cannot be split, so they count in neither stage. 112 of all 137 are not browser user agents (curl, Sparkle, link-preview crawlers).
- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.
## Sources
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
- Generated 2026-10-29T18:05:00Z.

View File

@@ -0,0 +1 @@
# pending

156
marketing/positioning.md Normal file
View File

@@ -0,0 +1,156 @@
# The Nook — Positioning (source of truth)
Every drafted asset draws its voice, claims, and constraints from this file. If an
artifact invents its own framing, fix the artifact or fix this file — never both
silently diverging.
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 count
correction, against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354],
[FRE-997]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
no longer exists; the verdicts below are the source of truth it was pointing at.
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
## The sentence
There is no single approved public sentence yet, and saying otherwise is the one
mistake this file exists to prevent. What exists is a **launch claim set** — two
sentences, both from one 15-second take, recorded as the chosen route by the Chief
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
| Slot | Exact sentence | Status today |
|---|---|---|
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
Working line for internal writing, not for publication:
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
> see what is running, you unblock what is waiting, and the machine stays cool
> while it works.
## The wedge
The thing to say over and over, in every channel, in slightly different words:
> **An agent blocked on a permission prompt is the most expensive kind of idle.**
> You pay for the model, you pay for the wait, and you get neither.
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
Allow, work resumes. Ten seconds, no explanation needed.
## The consolidation reframe
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
app reads like a grab bag. Reframe as consolidation, which is the honest story:
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
> strip, and a notification hack. One purchase, no subscription.
**No price, no discount and no future price in copy.** The price is $10, it stays
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
price change is a Chief of Staff + sponsor call; it is never a marketing decision
and never a launch asset.
## Hard rules
### 1. Category nouns, never product nouns
`freno-dev/AGENTS.md` forbids naming any competitor in user-facing copy — landing
pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all of it.
- Use **category nouns**: "notch app", "menu-bar monitor", "fan control utility",
"battery charge limiter", "agent notification hack". These carry the same search
intent and are fully compliant.
- Compare against **the status quo, not a competitor**: "vs. a terminal bell you
don't hear", "vs. four utilities in your menu bar", "vs. finding out an hour later."
- The rule does not reach App Store–external review replies, direct email, and
community replies written by a human, where quoting what a user said about another
tool is normal conversation.
### 2. A claim needs a verdict, not just a test
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
tables below, it does not go in copy.
### 3. Honest privacy scoping
Telemetry and the licence check **do** leave the device: Sentry runs on **every
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
- "nothing leaves your Mac"
The one favourable engine sentence — about the **hook and bridge only**, never
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
service**. Do not write "talk to nothing but your Mac" (too absolute: the
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
it to "over SSH" (a tailnet peer dials directly).
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
**held** until the disclosures land: Sentry gated behind a preference or removed,
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
false ones is worse than no sentence.
### 4. Sell what works today
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
all, and never claim approvals-from-phone as a shipped capability without its own
CTO verdict — no "(coming soon)" parentheticals either way.
Remote SSH is **held**, not approved: the one-click install has not
been verified end to end ([FRE-315] §1) and the shipped path needs hand-edited
`~/.ssh/config` and a one-time `ssh-copy-id` ([FRE-320]). Its copy stays parked in
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow **and** a
recording shows a remote agent joining the notch.
### 5. Humans hold every account
No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
without disclosure. `approved/` **is the only thing a human ever publishes from** —
nothing posts itself.
## Claims: status at 2026-11-03
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
| Claim | Status | Evidence / next step |
|---|---|---|
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
| Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable. |
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
| Remote agents over SSH, one click to install | **Held** | [FRE-315] §1, [FRE-320], [FRE-323]. Needs a verified one-click install plus a recording. |
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
## Voice
- Short, concrete, no hype adjectives. Numbers over adjectives — the only count
that ships is eighteen, and it is the registry's own size (see the coverage row
above).
- The notch is the product's home; agents are the product's point.
- Never apologize for being a utility. The utility is the point.
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
"game-changer", "seamlessly", any unverifiable superlative — and the specific
banned sentences in rule 3 and the table above.

View File

@@ -0,0 +1 @@
# posted

View File

@@ -1,5 +1,5 @@
{
"name": "example-with-trpc",
"name": "freno.(dev/me)",
"type": "module",
"scripts": {
"dev": "vinxi dev",
@@ -11,12 +11,13 @@
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"perf": "bun run scripts/perf-test.ts",
"perf:compare": "bun run scripts/perf-compare.ts"
"perf:compare": "bun run scripts/perf-compare.ts",
"metrics:weekly": "bun --env-file=.env scripts/weekly-metrics.ts",
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.953.0",
"@aws-sdk/s3-request-presigner": "^3.953.0",
"@clerk/backend": "^3.12.0",
"@libsql/client": "^0.15.15",
"@sentry/solidstart": "^10.67.0",
"@solidjs/meta": "^0.29.4",
@@ -54,7 +55,6 @@
"motion": "^12.23.26",
"solid-js": "^1.9.5",
"solid-tiptap": "^0.8.0",
"svix": "^1.98.0",
"ua-parser-js": "^2.0.7",
"uuid": "^13.0.0",
"vinxi": "^0.5.7",

View File

@@ -0,0 +1,4 @@
<!-- Official Claude mark — Anthropic favicon, https://claude.ai/favicon.svg -->
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,4 @@
<!-- Codex mark — official OpenAI Codex X (canonical path used by Cloudflare docs / lobe-icons / shadcn logos, all sourced from the OpenAI brand), white colorway for dark surfaces -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFFFFF" fill-rule="evenodd">
<path d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

BIN
public/nook/og.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -1,9 +1,21 @@
User-agent: *
Allow: /
Allow: /blog
Allow: /projects
Disallow: /login
Disallow: /debug/
# Private / dynamic paths — not for indexing.
# Keeps crawlers off auth, admin, and API pages so they don't burn
# uncached SSR function invocations.
Disallow: /account
Disallow: /analytics
Disallow: /api/
Disallow: /blog/create
Disallow: /blog/edit
Disallow: /checkout
Disallow: /databaseMGMT
Disallow: /debug/
Disallow: /error-test
Disallow: /login
Disallow: /success
Disallow: /test
Sitemap: https://www.freno.me/sitemap.xml

View File

@@ -0,0 +1,21 @@
import { ConnectionFactory } from "~/server/db-connections";
import { backfillDownloads } from "~/server/backfill-downloads";
// One-off: derive canonical /api/downloads/<asset> rows from download history
// recorded before `trackDownload` existed. Idempotent — re-running adds
// nothing, because derived rows carry a `backfill-<source id>` primary key.
//
// bun --env-file=.env scripts/backfill-downloads.ts
const result = await backfillDownloads(ConnectionFactory());
if (result.length === 0) {
console.log("Nothing to backfill: no legacy download rows left to derive.");
} else {
for (const { asset, added } of result) {
console.log(`${asset}: +${added}`);
}
console.log(
`Total: +${result.reduce((sum, row) => sum + row.added, 0)} derived download rows.`
);
}

38
scripts/upload-appcast.ts Normal file
View File

@@ -0,0 +1,38 @@
// One-off: upload the backfilled appcast to the download bucket.
// Usage: cd ~/Code/freno-dev && bun --env-file=.env /tmp/upload-appcast.ts <file>
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { readFileSync } from "node:fs";
const [, , file] = process.argv;
if (!file) {
console.error("usage: bun --env-file=.env upload-appcast.ts <appcast.xml>");
process.exit(1);
}
const body = readFileSync(file);
const xml = body.toString("utf8");
// Guard: refuse to upload without all three release descriptions present.
for (const v of ["0.6.0", "0.6.2", "0.6.3"]) {
if (!xml.includes(`sparkle:shortVersionString>${v}<`)) {
console.error(`refusing: item for ${v} missing`);
process.exit(1);
}
}
const client = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.MY_AWS_ACCESS_KEY!,
secretAccessKey: process.env.MY_AWS_SECRET_KEY!
}
});
await client.send(
new PutObjectCommand({
Bucket: process.env.VITE_DOWNLOAD_BUCKET_STRING,
Key: "api/TheNook/appcast.xml",
Body: body,
ContentType: "application/xml; charset=utf-8"
})
);
console.log("uploaded", file, "->", "api/TheNook/appcast.xml");

159
scripts/weekly-metrics.ts Normal file
View File

@@ -0,0 +1,159 @@
import { createClient } from "@libsql/client/web";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import {
ensureAnalyticsSchema,
getWeeklyReport,
renderWeeklyMarkdown,
type Connections
} from "~/server/weekly-metrics";
/**
* Write `marketing/metrics/weekly.md` from the live analytics and licence DBs.
*
* bun --env-file=.env scripts/weekly-metrics.ts
* bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z
*
* Required environment (all four; the analytics token also opens the licence
* DB, which is why the weekly job can run from a checkout that has no Stripe
* or Apple secrets):
*
* TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics
* NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations
*
* This script deliberately does not import `~/env/server`: that schema demands
* every production secret, and the metrics job needs two databases and nothing
* else.
*
* The output path is anchored at the repo root, so running it from any cwd
* writes the same file.
*/
interface Options {
days: number;
now: Date;
out: string;
stdout: boolean;
}
function usage(): string {
return [
"Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]",
"",
" --days N window length in days (default 7)",
" --now ISO end of the window (default: now)",
" --out PATH where to write (default: marketing/metrics/weekly.md)",
" --stdout print the report instead of writing a file"
].join("\n");
}
function parseArgs(argv: string[]): Options {
const repoRoot = resolve(import.meta.dir, "..");
const options: Options = {
days: 7,
now: new Date(),
out: resolve(repoRoot, "marketing/metrics/weekly.md"),
stdout: false
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
const value = () => {
const next = argv[i + 1];
if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`);
i += 1;
return next;
};
switch (arg) {
case "--days": {
const days = Number(value());
if (!Number.isFinite(days) || days <= 0) {
throw new Error(`--days must be a positive number\n\n${usage()}`);
}
options.days = days;
break;
}
case "--now": {
const now = new Date(value());
if (Number.isNaN(now.getTime())) {
throw new Error(`--now must be an ISO timestamp\n\n${usage()}`);
}
options.now = now;
break;
}
case "--out":
options.out = resolve(value());
break;
case "--stdout":
options.stdout = true;
break;
case "-h":
case "--help":
console.log(usage());
process.exit(0);
default:
throw new Error(`Unknown argument ${arg}\n\n${usage()}`);
}
}
return options;
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value || value.trim() === "") {
throw new Error(
`${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` +
`NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` +
`\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.`
);
}
return value;
}
function connect(url: string, token: string) {
return createClient({ url, authToken: token });
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const connections: Connections = {
analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")),
nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN"))
};
// The app migrates the table on boot, but this job must not depend on a
// deploy having happened: add any missing post-hoc columns (stage, host)
// through the same idempotent bootstrap path first.
await ensureAnalyticsSchema(connections.analytics);
const report = await getWeeklyReport(connections, {
days: options.days,
now: options.now
});
const markdown = renderWeeklyMarkdown(report);
if (options.stdout) {
process.stdout.write(markdown);
return;
}
await mkdir(dirname(options.out), { recursive: true });
await writeFile(options.out, markdown, "utf8");
const f = report.counts;
console.log(
`wrote ${options.out}\n` +
`window ${report.window.start} → ${report.window.end}\n` +
`visit ${f.visit}, download click ${f.downloadClick}, DMG served ${f.dmgServed}, ` +
`trial ${f.trial}, checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` +
`mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}`
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

View File

@@ -755,3 +755,114 @@ a.hover-underline-animation:hover::after {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ── Nook landing: newer demos ─────────────────────────────────── */
.nook-caret {
animation: nook-pulse 0.8s steps(2, jump-none) infinite;
}
/* Live signal inside a mixer fader */
.nook-meter {
transform-origin: left center;
animation: nook-meter 0.6s ease-in-out infinite alternate;
}
@keyframes nook-meter {
0% { transform: scaleX(0.35); }
40% { transform: scaleX(0.8); }
100% { transform: scaleX(0.55); }
}
/* Waveform slot bars */
.nook-wave {
height: 100%;
transform-origin: center;
animation: nook-wave 0.8s ease-in-out infinite alternate;
}
@keyframes nook-wave {
0% { transform: scaleY(0.25); }
100% { transform: scaleY(1); }
}
/* Now Playing progress and artwork sheen */
.nook-progress {
animation: nook-progress 14s linear infinite;
}
@keyframes nook-progress {
from { width: 8%; }
to { width: 100%; }
}
.nook-art {
animation: nook-art 9s linear infinite;
}
@keyframes nook-art {
to { filter: hue-rotate(360deg); }
}
/* A ringing timer's tape breathes between 35 % and full */
.nook-tape-ring {
animation: nook-tape-ring 0.9s ease-in-out infinite alternate;
}
@keyframes nook-tape-ring {
from { opacity: 0.35; }
to { opacity: 1; }
}
/* Keep Awake coffee steam */
.nook-steam path {
animation: nook-steam 1.8s ease-in-out infinite;
}
@keyframes nook-steam {
0% { transform: translateY(2px); opacity: 0; }
40% { opacity: 1; }
100% { transform: translateY(-4px); opacity: 0; }
}
/* Two-way feature marquee; edges fade, hover pauses */
.nook-marquee {
overflow: hidden;
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.nook-marquee-track {
display: flex;
gap: 0.625rem;
width: max-content;
padding-right: 0.625rem;
animation: nook-marquee 60s linear infinite;
}
.nook-marquee-track.reverse {
animation-direction: reverse;
}
.nook-marquee:hover .nook-marquee-track {
animation-play-state: paused;
}
@keyframes nook-marquee {
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.nook-meter,
.nook-wave,
.nook-progress,
.nook-art,
.nook-tape-ring,
.nook-steam path,
.nook-caret {
animation: none;
}
.nook-marquee {
mask-image: none;
}
.nook-marquee-track {
animation: none;
flex-wrap: wrap;
justify-content: center;
width: auto;
padding: 0 1rem;
}
.nook-marquee-track > [aria-hidden="true"] {
display: none;
}
}

View File

@@ -0,0 +1,29 @@
import { HttpHeader } from "@solidjs/start";
/**
* Renders edge-cache response headers for a page route.
*
* Sets `CDN-Cache-Control` so Vercel serves the rendered HTML from the edge
* (no origin re-render) for `maxAge` seconds, then stale-while-revalidate up
* to `staleSeconds`. `Cache-Control: public, max-age=0` keeps browsers
* revalidating, so visitors always get the latest version — only the CDN
* holds a cached copy. Function/CND-Cache-Control overrides the Vercel
* default, so repeated visits and bot crawls stop re-rendering at origin.
*/
export function EdgeCacheHeaders(props: {
/** Seconds the CDN may serve the response fresh. */
maxAge: number;
/** Seconds the CDN serves stale while revalidating (default: 1 day). */
staleSeconds?: number;
}) {
const stale = props.staleSeconds ?? 86400;
return (
<>
<HttpHeader name="Cache-Control" value="public, max-age=0" />
<HttpHeader
name="CDN-Cache-Control"
value={`public, s-maxage=${props.maxAge}, stale-while-revalidate=${stale}`}
/>
</>
);
}

View File

@@ -132,12 +132,17 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
});
describe("resolvePageHeadMeta — OpenGraph fallbacks", () => {
it("ogImage defaults to the site's ogDefaultImage when not provided", () => {
it("ogImage defaults to the site's ogDefaultImage, resolved to an absolute URL", () => {
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/");
expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage);
expect(meta.ogImage).toBe("https://nessa.freno.me/nessa/og-default.png");
});
it("explicit ogImage overrides the site default", () => {
it("the nook default resolves to the absolute og.png URL", () => {
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nook, "/");
expect(meta.ogImage).toBe("https://nook.freno.me/nook/og.png");
});
it("explicit absolute ogImage passes through unchanged", () => {
const meta = resolvePageHeadMeta(
{ ...BASE_PROPS, ogImage: "https://cdn/custom.png" },
SITE_CONFIG.main,
@@ -146,6 +151,15 @@ describe("resolvePageHeadMeta — OpenGraph fallbacks", () => {
expect(meta.ogImage).toBe("https://cdn/custom.png");
});
it("explicit site-relative ogImage is resolved against the site domain", () => {
const meta = resolvePageHeadMeta(
{ ...BASE_PROPS, ogImage: "/og/custom.png" },
SITE_CONFIG.gaze,
"/"
);
expect(meta.ogImage).toBe("https://gaze.freno.me/og/custom.png");
});
it("ogTitle falls back to the base title (no suffix)", () => {
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
expect(meta.ogTitle).toBe("Blog");

View File

@@ -56,6 +56,8 @@ export default function PageHead(props: PageHeadProps) {
<Meta property="og:description" content={meta().ogDescription} />
)}
<Meta property="og:image" content={meta().ogImage} />
{/* X/Twitter large-image card; falls back to the og:* tags above. */}
<Meta name="twitter:card" content="summary_large_image" />
</>
);
}

View File

@@ -4,16 +4,16 @@ import { buildMainSiteUrl } from "~/lib/site-context";
export default function SubdomainFooter() {
return (
<footer class="border-surface0 bg-surface0 relative z-10 border-t py-8">
<div class="relative flex items-center text-sm">
<div class="relative flex flex-col items-center gap-2 text-sm sm:flex-row sm:justify-center">
<A
href={buildMainSiteUrl()}
class="text-text/60 hover:text-text/80 mx-auto text-center underline underline-offset-4 transition-colors"
class="text-text/60 hover:text-text/80 text-center underline underline-offset-4 transition-colors"
>
made with <span class="text-red-400">&lt;3</span>
</A>
<A
href={buildMainSiteUrl("/downloads")}
class="text-text/80 hover:text-text absolute right-4 underline underline-offset-4 transition-colors"
class="text-text/80 hover:text-text underline underline-offset-4 transition-colors sm:absolute sm:right-4"
>
see more products
</A>

View File

@@ -9,20 +9,34 @@ import {
import {
IslandPill,
AgentDotGrid,
STATUS,
CALM,
WARM,
ACCENT,
CRITICAL,
ModuleCard
ModuleCard,
FakeCursor
} from "./IslandMock";
import { Campfire, type CampfireState } from "./Campfire";
import {
ApprovalCardMock,
SessionRowMock,
CLAUDE,
CODEX
} from "./ModuleMocks";
import { useDemoLoop } from "./useDemoLoop";
import {
TerminalReplyDemo,
MixerDemo,
CaptureDemo,
FloatingActionsDemo
} from "./MoreDemos";
/**
* Detailed feature breakdowns: alternating copy + live-demo sections.
* Each demo stages the real interaction loop: attention flares, approval
* resolves, fans throttle, the charge ceiling holds, meetings surface,
* the camera wakes, the hook installs.
* the camera wakes. Newer demos live in
* MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx.
*/
/* Scroll-triggered reveal: adds `in` once the element crosses 25%. */
@@ -165,100 +179,88 @@ function FleetDemo() {
);
}
/* ── Demo 2: permission card resolves, phone push follows ─────────── */
/* ── Demo 2: an approval surfaces the island; Allow resumes the run ── */
function ApprovalDemo() {
const [allowed, setAllowed] = createSignal(false);
onMount(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
const loop = () => {
setAllowed(false);
timers.push(setTimeout(() => setAllowed(true), 4200));
};
loop();
const interval = setInterval(loop, 8400);
onCleanup(() => {
clearInterval(interval);
timers.forEach(clearTimeout);
});
});
const [phase, setPhase] = createSignal<"calm" | "ask" | "press" | "resumed">("calm");
useDemoLoop(10400, [
[0, () => setPhase("calm")],
[1400, () => setPhase("ask")],
[4600, () => setPhase("press")],
[4950, () => setPhase("resumed")],
[8800, () => setPhase("calm")]
]);
const asking = () => phase() === "ask" || phase() === "press";
const open = () => phase() !== "calm";
return (
<div class="flex flex-col items-center gap-5 sm:flex-row sm:items-end sm:justify-center">
{/* island card */}
<div
class="w-full max-w-[300px] rounded-[10px] p-2.5 text-left transition-opacity duration-500"
style={{
background: "rgba(234,179,8,0.08)",
opacity: allowed() ? 0.45 : 1
}}
>
<div class="mb-1.5 flex items-center gap-1.5">
<span
class="inline-block h-[13px] w-[13px] rounded-[3px]"
style={{ background: "#D97742" }}
<div class="relative flex h-[390px] flex-col items-center gap-3">
<IslandPill
campfire={asking() ? "question" : "running"}
right={
<AgentDotGrid
states={asking() ? ["attention", "running", "ready"] : ["running", "running", "ready"]}
/>
<span class="text-[12px] font-semibold text-white">
the-nook · bridge
</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{
background: allowed()
? "rgba(150,220,150,0.25)"
: "rgba(234,179,8,0.25)"
}}
>
{allowed() ? "allowed" : "approval"}
</span>
</div>
<p class="text-[13px] font-medium text-white">Run Edit</p>
<p class="mt-0.5 font-mono text-[11px] text-white/50">
server/routes.ts +12 −4
</p>
<div class="mt-2.5 flex gap-2">
<span
class="rounded-md px-3 py-1 text-[11px] font-semibold text-white transition-all duration-300"
style={{ background: ACCENT, opacity: allowed() ? 0.4 : 1 }}
>
Allow
</span>
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
Deny
</span>
</div>
</div>
{/* phone */}
}
/>
{/* the island opens: approval pinned above the session rows */}
<div
class="w-[150px] rounded-2xl border border-white/15 p-2 text-left"
class="w-full max-w-[380px] overflow-hidden rounded-[14px] bg-black transition-all duration-700"
style={{
background: "rgba(255,255,255,0.04)",
transform: allowed() ? "translateY(-4px)" : "translateY(0)",
transition: "transform 0.5s ease"
"max-height": open() ? "320px" : "0px",
opacity: open() ? 1 : 0,
transform: open() ? "translateY(0)" : "translateY(-8px)"
}}
>
<div class="mx-auto mb-1.5 h-1 w-10 rounded-full bg-white/20" />
<div class="space-y-1.5">
<p class="px-1 text-[9px] font-semibold tracking-wide text-white/40 uppercase">
The Nook
</p>
<div class="p-2.5">
<div
class="rounded-lg p-2"
style={{
background: allowed()
? "rgba(111,185,130,0.14)"
: "rgba(231,167,98,0.14)"
}}
class="grid transition-all duration-500"
style={{ "grid-template-rows": asking() ? "1fr" : "0fr", opacity: asking() ? 1 : 0 }}
>
<p class="text-[10px] font-semibold text-white">
{allowed() ? "Allowed ✓" : "Claude Code wants to edit"}
</p>
<p class="text-[9px] text-white/50">
{allowed() ? "resumed" : "tap to Allow or Deny"}
</p>
<div class="overflow-hidden">
<div class="pb-1.5">
<ApprovalCardMock
agent={CLAUDE}
title="the-nook · harden the bridge"
tool="Bash"
input="swift test --filter Bridge"
pressed={phase() === "press" ? "allow" : undefined}
/>
</div>
</div>
</div>
<div class="space-y-1.5">
<SessionRowMock
title="the-nook · main"
prompt="harden the bridge"
state={
asking()
? "Approve Bash — swift test --filter Bridge?"
: "Running swift test --filter Bridge…"
}
status={asking() ? "attention" : "running"}
agent={CLAUDE}
origin="iTerm2"
age="6m"
/>
<SessionRowMock
title="site · nook-334"
prompt="match the mocks to the app"
state="Reading ModuleMocks.tsx…"
status="running"
agent={CODEX}
origin="kitty-tmux"
age="2m"
/>
</div>
</div>
</div>
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-[380px] -translate-x-1/2">
<FakeCursor
phase={phase() === "press" ? "press" : asking() ? "idle" : "live"}
at={{ x: 9.5, y: 41 }}
exit={{ x: 30, y: 70 }}
/>
</div>
</div>
);
}
@@ -406,37 +408,6 @@ function FansDemo() {
);
}
/** Fake cursor dot: rests on a target, presses, exits. */
function FakeCursor(props: {
phase: "idle" | "press" | "live";
at: { x: number; y: number };
exit?: { x: number; y: number };
}) {
const p = () =>
props.phase === "press"
? { ...props.at, scale: 0.62, o: 1 }
: props.phase === "idle"
? { ...props.at, scale: 1, o: 1 }
: { ...(props.exit ?? { x: 106, y: 8 }), scale: 1, o: 0 };
return (
<span
class="pointer-events-none absolute z-10 rounded-full bg-white"
style={{
width: "9px",
height: "9px",
"box-shadow":
"0 1px 4px rgba(0,0,0,0.6), 0 0 0 4px rgba(255,255,255,0.18)",
left: `${p().x}%`,
top: `${p().y}%`,
opacity: p().o,
transform: `translate(-50%, -50%) scale(${p().scale})`,
transition:
"left 0.8s cubic-bezier(0.35,0,0.25,1), top 0.8s cubic-bezier(0.35,0,0.25,1), opacity 0.4s, transform 0.3s"
}}
/>
);
}
/* ── Demo 4: the charge ceiling holds, top-up overrules ───── */
function BatteryDemo() {
@@ -623,7 +594,9 @@ function CalendarDemo() {
});
};
return (
<div class="flex flex-col items-center gap-4">
/* Fixed height: the day panel expands into reserved space, never
shifts the page (matters most on mobile's stacked layout). */
<div class="flex h-[316px] flex-col items-center gap-4">
<IslandPill
campfire="running"
right={
@@ -762,8 +735,15 @@ function CameraDemo() {
});
});
createEffect(() => {
if (phase() === "live") videoRef?.play();
else videoRef?.pause();
if (phase() === "live" && videoRef) {
videoRef.currentTime = 0;
// play() rejects with AbortError when interrupted by pause() (the loop
// resetting to "idle") or when the browser pauses background media to
// save power. Swallow it — the rejection is expected, not a failure.
void videoRef.play().catch(() => {});
} else {
videoRef?.pause();
}
});
return (
<ModuleCard class="relative mx-auto w-full max-w-[300px]">
@@ -807,9 +787,9 @@ function CameraDemo() {
ref={videoRef}
src="/nook/cam-recording.mp4"
muted
loop
playsinline
preload="metadata"
onEnded={() => setPhase("idle")}
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-700"
style={{ opacity: phase() === "live" ? 1 : 0 }}
/>
@@ -825,145 +805,6 @@ function CameraDemo() {
);
}
/* ── Demo 7: one click copies, sets up, probes — pong ─────── */
const SSH_LINES = [
{ at: 1, text: "scp nook-hook gpu-rig:~/.nook/bin/" },
{ at: 2, text: "setup: bake NOOK_HOST, write ~/.nook/endpoint" },
{ at: 3, text: "nook-hook --probe → pong" }
];
function SshInstallDemo() {
const [step, setStep] = createSignal(0);
onMount(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
const loop = () => {
setStep(0);
timers.push(setTimeout(() => setStep(1), 1600));
timers.push(setTimeout(() => setStep(2), 3000));
timers.push(setTimeout(() => setStep(3), 4200));
timers.push(setTimeout(() => setStep(4), 5400));
};
loop();
const interval = setInterval(loop, 11000);
onCleanup(() => {
clearInterval(interval);
timers.forEach(clearTimeout);
});
});
const cursorPhase = () =>
step() === 0
? ("idle" as const)
: step() === 1
? ("press" as const)
: ("live" as const);
return (
<ModuleCard class="relative mx-auto w-full max-w-[330px]">
<div class="space-y-2.5 text-left">
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
<p class="truncate font-mono text-[10px] text-white/40">
mike@192.168.64.3 · linux
</p>
</div>
<span
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-300"
style={{
background: step() === 0 ? ACCENT : "rgba(255,255,255,0.08)",
opacity: step() >= 4 ? 0 : 1,
transform: step() === 1 ? "scale(0.94)" : "scale(1)"
}}
>
{step() === 0 ? "Install hook" : "installing…"}
</span>
</div>
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
<div class="relative" style={{ "min-height": "86px" }}>
<div
class="space-y-1.5 transition-opacity duration-500"
style={{ opacity: step() >= 4 ? 0 : 1 }}
>
{step() === 0 && (
<p class="pt-2 text-center text-[10px] text-white/35">
one click: copy → setup → probe
</p>
)}
<For each={SSH_LINES}>
{(l) => (
<div
class="flex items-center gap-1.5 transition-all duration-500"
style={{
opacity: step() >= l.at ? 1 : 0,
transform:
step() >= l.at ? "translateY(0)" : "translateY(4px)"
}}
>
<span
class="font-mono text-[10px]"
style={{
color:
step() > l.at
? "rgba(150,220,150,0.9)"
: "rgba(255,255,255,0.45)"
}}
>
{step() > l.at ? "✓" : "…"}
</span>
<span class="truncate font-mono text-[10px] text-white/70">
{l.text}
</span>
</div>
)}
</For>
</div>
<div
class="absolute inset-0 flex flex-col justify-center transition-all duration-500"
style={{
opacity: step() >= 4 ? 1 : 0,
transform: step() >= 4 ? "translateY(0)" : "translateY(6px)"
}}
>
<div
class="rounded-[10px] p-2.5"
style={{
background: "rgba(255,255,255,0.055)",
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
}}
>
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full"
style={{ background: STATUS.running }}
/>
<span class="text-[13px] font-semibold text-white">
gpu-rig
</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
style={{
background: "rgba(110,167,255,0.22)",
color: "#9cc3ff"
}}
>
remote
</span>
</div>
<p class="mt-0.5 text-[11px] text-white/50">
You: benchmark fp16
</p>
</div>
<p class="pt-1.5 text-center text-[10px] text-white/40">
agents join the same dot grid
</p>
</div>
</div>
</div>
<FakeCursor phase={cursorPhase()} at={{ x: 90, y: 14 }} />
</ModuleCard>
);
}
/* ── Demo 8: campfire states ───────────────────────────────────────── */
function CampfireDemo() {
@@ -1010,8 +851,8 @@ export default function FeatureBreakdowns() {
title="Your whole fleet, in one pixel strip"
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
bullets={[
"12 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, and more",
"The campfire dances while work runs, simmers when all is calm",
"18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
"Custom agents: your own coding agent can wire itself in from the published contract",
"Slot rankings — you choose which data earns the notch",
"You don't need a notch — external displays get the same island, floating top-center"
]}
@@ -1022,17 +863,43 @@ export default function FeatureBreakdowns() {
<BreakdownSection
kicker="Approvals"
title="Answer from the island — or your phone"
body="Gated tool calls hold right in the panel. Allow, deny, or demand a reason. Walking away? The same request lands on your phone and your tap flies back to the agent."
body="A gated tool call surfaces the island: the fire turns to a question, the card pins above your sessions, and the row says what it's waiting on. Allow, Allow for session, Deny, or deny with a reason, and the run picks up where it stopped."
bullets={[
"Permission and question cards pin above the session list",
"Push-to-phone over your own network — no cloud middleman",
"Works across every hooked agent, local or remote"
"Approval cards pin above the session list; questions answer inside their row",
"Allow for session waves through the rest of that session's requests",
"Answer from iPhone or Apple Watch too, now in TestFlight beta",
]}
reversed
>
<ApprovalDemo />
</BreakdownSection>
<BreakdownSection
kicker="Terminals"
title="Reply without hunting for the tab"
body="Every session knows the terminal it runs in. Type a reply in the island and it lands in that exact pane; click Jump and the right window comes forward."
bullets={[
"Replies reach agents in tmux, iTerm2, Terminal, and kitty",
"Jump focuses the exact window, even for sessions over SSH",
"Per-turn replies render as Markdown, with tables and code"
]}
>
<TerminalReplyDemo />
</BreakdownSection>
<BreakdownSection
kicker="The campfire"
title="A hearth that tells the truth"
body="Stop-motion pixel art, four frames a step. It dances when tokens stream, putters blue when an agent asks, and goes to dead embers the moment something breaks."
bullets={[
"Idle, running, ready, question, and error — at a glance",
"Hand-drawn 14×14 sprites, no tweening",
"Six chiptunes you can assign to any agent event"
]}
>
<CampfireDemo />
</BreakdownSection>
<BreakdownSection
kicker="Cooling"
title="Fans that answer to you, not the scheduler"
@@ -1043,6 +910,7 @@ export default function FeatureBreakdowns() {
"CPU/GPU die temps tier-color before things get loud",
"All local: your hardware data never leaves the device"
]}
reversed
>
<FansDemo />
</BreakdownSection>
@@ -1058,11 +926,38 @@ export default function FeatureBreakdowns() {
"Thermal protection pauses charging when the pack runs hot",
"Energy flow figure: adapter → battery → system, live watts"
]}
reversed
>
<BatteryDemo />
</BreakdownSection>
<BreakdownSection
kicker="Sound"
title="Every app gets its own fader"
body="A real volume mixer: set each app's level, mute the noisy one, and send any app to its own output — music to headphones while the meeting stays on the speakers."
bullets={[
"Per-app volume, mute, and output routing",
"Master output, system sounds, and microphone pickers in one panel",
"Catches players that aren't apps, like command-line podcast tools"
]}
reversed
>
<MixerDemo />
</BreakdownSection>
<BreakdownSection
kicker="Capture"
title="Screenshots and recordings from the notch"
body="Drag out an area, pick a screen, or choose one app. While a recording runs, Wings unfold beside the island: pause and stop on the right, system audio and microphone on the left."
bullets={[
"Area, screen, or single-app capture",
"Screenshots save to your screenshot folder and copy to the clipboard",
"System audio and microphone tracks for recordings",
"The Nook's own windows never show up in a capture"
]}
>
<CaptureDemo />
</BreakdownSection>
<BreakdownSection
kicker="Your day"
title="Meetings come to the notch"
@@ -1073,6 +968,7 @@ export default function FeatureBreakdowns() {
"Meeting links get an Open button in the row",
"Real calendars and reminders — no sync service"
]}
reversed
>
<CalendarDemo />
</BreakdownSection>
@@ -1087,35 +983,23 @@ export default function FeatureBreakdowns() {
"A device picker for every connected camera",
"Opt-in module — in no default panel"
]}
reversed
>
<CameraDemo />
</BreakdownSection>
<BreakdownSection
kicker="Remote"
title="Any SSH box, one click away"
body="Point at any SSH-reachable box and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
kicker="Floating actions"
title="Your buttons, orbiting the island"
body="Pin up to seven one-tap actions around the expanded island. They ride its expansion in and out, so the things you reach for most are always one click away."
bullets={[
"One click: copy the hook, run setup, probe answers pong",
"Remote agents wear a blue badge in the same dot grid",
"Works for Claude, Codex, Pi and many more"
"Run an Apple Shortcut, open an app or URL, or run a shell command",
"Toggle Low Power Mode, top-up, or overheat protection",
"Set the charge limit, cycle audio outputs, or start a timer",
"Cycle the approval mode without opening Settings"
]}
reversed
>
<SshInstallDemo />
</BreakdownSection>
<BreakdownSection
kicker="The campfire"
title="A hearth that tells the truth"
body="Stop-motion pixel art, four frames a step. It dances when tokens stream, putters blue when an agent asks, and goes to dead embers the moment something breaks."
bullets={[
"Idle, running, ready, question, and error — at a glance",
"Hand-drawn 14×14 sprites, no tweening",
"The fire is the fleet's pulse: glanceable from across the room"
]}
>
<CampfireDemo />
<FloatingActionsDemo />
</BreakdownSection>
</div>
);

View File

@@ -1,13 +1,6 @@
import { For, type JSX } from "solid-js";
import {
IslandPill,
AgentDotGrid,
STATUS,
CALM,
CRITICAL,
ACCENT,
ModuleCard
} from "./IslandMock";
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
/**
* Feature collage: five cells sold as the app's actual UI. The hero cell
@@ -16,7 +9,7 @@ import {
* module chrome, status palette, and metric language.
*/
function CollageCell(props: {
export function CollageCell(props: {
class?: string;
kicker: string;
title: string;
@@ -44,54 +37,13 @@ function CollageCell(props: {
* is always dark (preferredColorScheme .dark), so the mocks read correctly
* in both site themes.
*/
function InkStage(props: { children: JSX.Element; class?: string }) {
return (
<div class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`} style={{ background: "#0d0d0f" }}>
{props.children}
</div>
);
}
/** The approvals mock rebuilt on the app's PermissionCard anatomy. */
function ApprovalCardMock() {
export function InkStage(props: { children: JSX.Element; class?: string }) {
return (
<div
class="mx-auto w-full max-w-sm rounded-[10px] p-2.5 text-left"
style={{ background: "rgba(234,179,8,0.08)" }}
class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`}
style={{ background: "#0d0d0f" }}
>
<div class="mb-1.5 flex items-center gap-1.5">
<span
class="inline-block h-[13px] w-[13px] rounded-[3px]"
style={{ background: "#D97742" }}
/>
<span class="text-[12px] font-semibold text-white">
the-nook · bridge
</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{ background: "rgba(234,179,8,0.25)" }}
>
approval
</span>
</div>
<p class="text-[13px] font-medium text-white">Run Edit</p>
<p class="mt-0.5 font-mono text-[11px] text-white/50">
server/routes.ts +12 −4
</p>
<div class="mt-2.5 flex gap-2">
<span
class="rounded-md px-3 py-1 text-[11px] font-semibold"
style={{ background: ACCENT }}
>
Allow
</span>
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
Deny
</span>
<span class="px-1 py-1 text-[11px] text-white/50">
Deny with reason…
</span>
</div>
{props.children}
</div>
);
}
@@ -147,97 +99,37 @@ function FansPanelMock() {
);
}
/** Remote agents: two session rows like the expanded agents panel. */
function RemoteMock() {
/* Deterministic usage wander for the traces. */
const wander = (n: number, k: number) =>
0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k);
/**
* The Temperatures module: one line per die — label, its 60 s usage trace,
* its temperature. The GPU climbs past the 70 ° warn line and tints amber.
*/
function GaugesMock() {
const [n, setN] = createSignal(0);
onMount(() => {
const interval = setInterval(() => setN((v) => v + 1), 700);
onCleanup(() => clearInterval(interval));
});
const trace = (k: number) =>
Array.from({ length: 30 }, (_, i) => Math.max(0.04, Math.min(0.97, wander(n() + i, k))));
const gpuCelsius = () => 62 + 12 * (0.5 + 0.5 * Math.sin(n() * 0.18));
return (
<div class="space-y-1.5">
<div
class="rounded-[10px] p-2.5"
style={{ background: "rgba(255,255,255,0.055)", "box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)" }}
>
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full"
style={{ background: STATUS.running }}
/>
<span class="text-[13px] font-semibold text-white">build-box</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
style={{ background: "rgba(255,255,255,0.08)" }}
>
ssh
</span>
</div>
<p class="mt-0.5 text-[11px] text-white/50">You: fix flaky test</p>
<ModuleCard>
<div class="space-y-2">
<DieLine label="CPU" usage={trace(0)} celsius={58 + 4 * Math.sin(n() * 0.3)} />
<DieLine label="GPU" usage={trace(2)} celsius={gpuCelsius()} />
</div>
<div
class="rounded-[10px] p-2.5"
style={{ background: "rgba(255,255,255,0.055)", "box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)" }}
>
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full"
style={{ background: STATUS.ready }}
/>
<span class="text-[13px] font-semibold text-white">gpu-rig</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
style={{ background: "rgba(255,255,255,0.08)" }}
>
ssh
</span>
</div>
<p class="mt-0.5 text-[11px] text-white/50">Ready</p>
</div>
</div>
</ModuleCard>
);
}
/** System gauges: temp die blocks + memory, in module cards. */
function GaugesMock() {
return (
<div class="grid grid-cols-2 gap-2">
<ModuleCard>
<div class="flex items-baseline justify-between">
<span class="text-[9px] font-semibold text-white/50">CPU</span>
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
72°
</span>
</div>
<div
class="mt-1.5 h-1 overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{ width: "64%", background: CALM }}
/>
</div>
</ModuleCard>
<ModuleCard>
<div class="flex items-baseline justify-between">
<span class="text-[9px] font-semibold text-white/50">GPU</span>
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
58°
</span>
</div>
<div
class="mt-1.5 h-1 overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{ width: "31%", background: CALM }}
/>
</div>
</ModuleCard>
</div>
);
}
export default function FeatureCollage() {
return (
<section
class="bg-base relative z-20 -mt-28 px-4 pb-16 pt-40 md:px-8"
class="bg-base relative z-20 px-4 pt-40 pb-16 md:px-8"
id="feature-collage"
>
<div class="mx-auto max-w-5xl">
@@ -250,9 +142,7 @@ export default function FeatureCollage() {
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
{/* Hero: real collapsed pill on the island surface */}
<div
class="border-overlay1 bg-surface1 relative col-span-1 flex flex-col items-center overflow-hidden rounded-2xl border p-6 md:col-span-2"
>
<div class="border-overlay1 bg-surface1 relative col-span-1 flex flex-col items-center overflow-hidden rounded-2xl border p-6 md:col-span-2">
<div class="mb-5 self-start">
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
The island
@@ -261,9 +151,9 @@ export default function FeatureCollage() {
Every agent, one glance
</h3>
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
A pill tucked into your notch — a campfire for your whole
fleet on the left, live data on the right. Blue running,
green ready, amber needs you.
A pill tucked into your notch — a campfire for your whole fleet
on the left, live data on the right. Blue running, green ready,
amber needs you.
</p>
</div>
<div class="my-10 flex w-full justify-center">
@@ -304,11 +194,16 @@ export default function FeatureCollage() {
<CollageCell
kicker="Approvals"
title="Unblock agents, anywhere"
body="Permission prompts surface as cards in the panel — Allow once, always, or deny. Away from the desk, push them to your phone and answer from there."
body="Permission prompts pin as cards above your sessions — Allow, Allow for session, or Deny. Away from the desk, answer from your iPhone or Apple Watch (beta)."
>
<InkStage>
<div class="w-full max-w-sm px-3">
<ApprovalCardMock />
<ApprovalCardMock
agent={CLAUDE}
title="the-nook · bridge"
tool="Edit"
input="server/routes.ts"
/>
</div>
</InkStage>
</CollageCell>
@@ -326,24 +221,11 @@ export default function FeatureCollage() {
</InkStage>
</CollageCell>
{/* Remote agents */}
<CollageCell
kicker="Remote"
title="Agents on other machines"
body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island."
>
<InkStage class="w-full">
<div class="w-full max-w-[280px] text-left">
<RemoteMock />
</div>
</InkStage>
</CollageCell>
{/* Gauges */}
<CollageCell
kicker="Your Mac"
title="The gauges that matter"
body="CPU and GPU die temps, memory pressure, live network throughput — the same tier palette the island uses, warning before a compile cooks your lap."
body="Each die's temperature beside its 60-second usage trace, tinted amber from 70° and red at 85°, so you see a compile heating up before the fans do."
>
<InkStage class="w-full">
<div class="w-full max-w-[300px] text-left">
@@ -354,8 +236,13 @@ export default function FeatureCollage() {
</div>
<p class="text-subtext1 mt-6 text-center text-xs">
Plus calendar, reminders, calls, and Now Playing — all in the same
island, all one-time purchase.
Plus a mixer, capture, timers, weather, and a dozen more modules —{" "}
<a
href="#island-extras"
class="underline decoration-dotted hover:opacity-80"
>
see them all ↓
</a>
</p>
</div>
</section>

View File

@@ -0,0 +1,899 @@
import {
For,
Index,
Show,
createSignal,
onCleanup,
onMount,
type JSX
} from "solid-js";
import {
ModuleCard,
STATUS,
ACCENT,
CALM,
WARM,
CRITICAL,
CARD_FILL,
CARD_STROKE
} from "./IslandMock";
import { CollageCell, InkStage } from "./FeatureCollage";
import { useDemoLoop } from "./useDemoLoop";
import { Glyph, TimerTapeMock, TIMER_ORANGE, type GlyphName } from "./ModuleMocks";
/**
* "Everything else": a bento of small live tiles for the modules that do
* not get a full breakdown, headed by a two-way marquee naming the rest.
*/
/** Steady tick for tiles that animate continuously rather than in steps. */
function useTick(everyMs: number) {
const [tick, setTick] = createSignal(0);
onMount(() => {
const interval = setInterval(() => setTick((t) => t + 1), everyMs);
onCleanup(() => clearInterval(interval));
});
return tick;
}
/** A compact black island strip for tiles that show a slot reading. */
function MiniPill(props: { children: JSX.Element; class?: string }) {
return (
<div
class={`mx-auto flex h-[26px] items-center justify-center gap-2 rounded-b-[11px] bg-black px-3 ${props.class ?? ""}`}
style={{ "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
>
{props.children}
</div>
);
}
/* ── Now Playing + the waveform slot ─────────────────────────────── */
function NowPlayingMock() {
return (
<div class="flex w-full max-w-[420px] flex-col items-center gap-4 px-3 sm:flex-row sm:items-center">
<ModuleCard class="w-full flex-1">
<div class="flex items-center gap-3 text-left">
<div
class="nook-art h-12 w-12 shrink-0 rounded-lg"
style={{
background:
"conic-gradient(from 210deg, #fa5c7c, #6EA7FF, #6FB982, #E7A762, #fa5c7c)"
}}
/>
<div class="min-w-0 flex-1">
<p class="truncate text-[12px] font-semibold text-white">
Night Drive
</p>
<p class="truncate text-[10px] text-white/50">
Synthwave Radio · Browser
</p>
<div
class="mt-2 h-[3px] overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.1)" }}
>
<div
class="nook-progress h-full rounded-full"
style={{ background: "rgba(255,255,255,0.75)" }}
/>
</div>
</div>
</div>
<div class="mt-2.5 flex justify-center gap-5 text-[12px] text-white/70">
<span>⏮</span>
<span class="text-white">⏸</span>
<span>⏭</span>
</div>
</ModuleCard>
<div class="flex flex-col items-center gap-1.5">
<MiniPill>
<span class="flex h-[14px] items-center gap-[2px]">
<For each={[0, 1, 2, 3, 4, 5]}>
{(i) => (
<span
class="nook-wave w-[2.5px] rounded-full"
style={{
background: "#fa5c7c",
"animation-delay": `${i * -0.13}s`,
"animation-duration": `${0.7 + (i % 3) * 0.18}s`
}}
/>
)}
</For>
</span>
</MiniPill>
<span class="text-[9px] text-white/40">waveform slot</span>
</div>
</div>
);
}
/* ── Timers: the module's face — readout over the tick tape ──────── */
/** TimerService.format: m:ss, rounding up like the app. */
function clock(seconds: number) {
const s = Math.ceil(seconds);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
/** The face's capsule control: 26 px, mono 11, white 8 %. */
function TimerPill(props: { glyph?: GlyphName; label?: string }) {
return (
<span class="flex h-[26px] flex-1 items-center justify-center gap-1 rounded-full bg-white/[0.08] font-mono text-[11px] font-medium whitespace-nowrap text-white">
<Show when={props.glyph}>
<Glyph name={props.glyph!} size={10} />
</Show>
{props.label}
</span>
);
}
/*
* A 3:00 lead timer drains (in fast-forward), rings with its tape pulsing,
* and a 25:00 Focus timer keeps running in the stacked row beneath.
*/
const LEAD_SECONDS = 180;
const LEAD_MS = 9000;
const TIMER_PERIOD = 14000;
function TimersMock() {
const tick = useTick(50);
const t = () => (tick() * 50) % TIMER_PERIOD;
const leadLeft = () => Math.max(0, 1 - t() / LEAD_MS);
const ringing = () => t() >= LEAD_MS;
const focusLeft = () => 0.64 - (t() / TIMER_PERIOD) * 0.06;
return (
<ModuleCard class="w-full max-w-[260px]">
<div class="flex flex-col items-center gap-1">
<div class="flex h-[34px] items-center">
<Show
when={ringing()}
fallback={
<span class="font-mono text-[30px] font-semibold text-white tabular-nums">
{clock(leadLeft() * LEAD_SECONDS)}
</span>
}
>
<span
class="flex items-center gap-1.5 font-mono text-[22px] font-semibold"
style={{ color: TIMER_ORANGE }}
>
<Glyph
name="bell"
size={18}
style={{ animation: "nook-pulse 1s ease-in-out infinite" }}
/>
Time's up
</span>
</Show>
</div>
<div class={ringing() ? "nook-tape-ring" : ""}>
<TimerTapeMock remaining={ringing() ? 1 : leadLeft()} width={232} height={32} />
</div>
<p class="font-mono text-[9px] font-medium text-white/50">
{ringing() ? "3:00 timer" : "Remaining of 3:00"}
</p>
<div class="flex w-full gap-2 pt-1">
<Show
when={ringing()}
fallback={
<>
<TimerPill glyph="pause" label="Pause" />
<TimerPill glyph="xmark" label="Cancel" />
</>
}
>
<TimerPill glyph="plus" label="1 min" />
<TimerPill glyph="restart" label="Restart" />
<TimerPill glyph="xmark" label="Dismiss" />
</Show>
</div>
{/* a further timer: readout, slim tape, icon controls */}
<div class="mt-1.5 flex h-6 w-full items-center gap-2 rounded-full bg-white/[0.05] px-2.5 font-mono text-[11px] font-medium text-white">
<span class="w-11 tabular-nums">{clock(focusLeft() * 1500)}</span>
<div class="min-w-0 flex-1">
<TimerTapeMock remaining={focusLeft()} width={110} height={12} />
</div>
<Glyph name="pause" size={9} />
<Glyph name="xmark" size={9} />
</div>
<div class="mt-1.5 flex w-full gap-1.5">
<For each={["1m", "5m", "10m", "25m"]}>{(m) => <TimerPill label={m} />}</For>
</div>
</div>
</ModuleCard>
);
}
/* ── Weather: saved places rotate through the slot ───────────────── */
type Sky = "sun" | "cloud" | "rain";
function SkyGlyph(props: { sky: Sky; size?: number }) {
const s = () => props.size ?? 14;
return (
<svg width={s()} height={s()} viewBox="0 0 16 16">
<Show when={props.sky === "sun"}>
<circle cx="8" cy="8" r="3.4" fill="#FFD23F" />
<g stroke="#FFD23F" stroke-width="1.3" stroke-linecap="round">
<path d="M8 1.2v1.6M8 13.2v1.6M1.2 8h1.6M13.2 8h1.6M3.2 3.2l1.1 1.1M11.7 11.7l1.1 1.1M3.2 12.8l1.1-1.1M11.7 4.3l1.1-1.1" />
</g>
</Show>
<Show when={props.sky !== "sun"}>
<path
d="M4.5 11.5a3 3 0 0 1 .3-6 4 4 0 0 1 7.6 1.2 2.4 2.4 0 0 1-.4 4.8Z"
fill="rgba(255,255,255,0.8)"
/>
</Show>
<Show when={props.sky === "rain"}>
<path
d="M6 13l-.6 1.6M9 13l-.6 1.6M12 13l-.6 1.6"
stroke={CALM}
stroke-width="1.2"
stroke-linecap="round"
/>
</Show>
</svg>
);
}
const PLACES: { place: string; temp: number; sky: Sky; hourly: [Sky, number][] }[] = [
{
place: "Portland",
temp: 13,
sky: "rain",
hourly: [["rain", 13], ["rain", 13], ["cloud", 14], ["cloud", 15], ["sun", 16], ["sun", 15]]
},
{
place: "Lisbon",
temp: 24,
sky: "sun",
hourly: [["sun", 24], ["sun", 25], ["sun", 26], ["cloud", 25], ["cloud", 23], ["sun", 21]]
}
];
function WeatherMock() {
const [i, setI] = createSignal(0);
useDemoLoop(7000, [
[0, () => setI(0)],
[3500, () => setI(1)]
]);
const p = () => PLACES[i()];
return (
<div class="flex w-full max-w-[260px] flex-col items-center gap-3">
<MiniPill class="h-[34px] flex-col gap-0 px-4">
<SkyGlyph sky={p().sky} size={12} />
<span class="font-mono text-[11px] leading-none font-bold text-white">
{p().temp}°
</span>
</MiniPill>
<ModuleCard class="w-full">
<div class="text-left">
<div class="mb-2 flex items-baseline">
<span class="text-[11px] font-semibold text-white">{p().place}</span>
<span class="ml-auto text-[9px] text-white/40">
{i() + 1} of {PLACES.length}
</span>
</div>
<div class="flex justify-between">
<Index each={p().hourly}>
{(h, hour) => (
<div class="flex flex-col items-center gap-1">
<span class="text-[8px] text-white/40">
{hour === 0 ? "Now" : `${(13 + hour) % 24}`}
</span>
<SkyGlyph sky={h()[0]} size={12} />
<span class="font-mono text-[10px] text-white/85">
{h()[1]}°
</span>
</div>
)}
</Index>
</div>
</div>
</ModuleCard>
</div>
);
}
/* ── Interceptions: volume & brightness peek below the island ────── */
const PEEKS = [
{ kind: "volume", label: "Speakers", from: 0.35, to: 0.7 },
{ kind: "bright", label: "Built-in display", from: 0.6, to: 1 },
{ kind: "xdr", label: "Extra brightness", from: 1, to: 1.3 },
{ kind: "bright", label: "External display", from: 0.3, to: 0.65 }
] as const;
function InterceptionMock() {
const [peek, setPeek] = createSignal(0);
const [level, setLevel] = createSignal<number>(PEEKS[0].from);
const [shown, setShown] = createSignal(false);
const steps: [number, () => void][] = [];
PEEKS.forEach((pk, n) => {
const base = n * 2600;
steps.push([base, () => (setPeek(n), setLevel(pk.from), setShown(true))]);
for (let k = 1; k <= 4; k++) {
steps.push([
base + 250 + k * 280,
() => setLevel(pk.from + ((pk.to - pk.from) * k) / 4)
]);
}
steps.push([base + 2300, () => setShown(false)]);
});
useDemoLoop(PEEKS.length * 2600, steps);
const pk = () => PEEKS[peek()];
const xdr = () => level() > 1;
return (
<div class="flex w-full max-w-[360px] flex-col items-center">
<div
class="relative overflow-hidden rounded-b-[16px] bg-black transition-all duration-300"
style={{
width: shown() ? "300px" : "200px",
height: shown() ? "68px" : "26px",
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)",
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
}}
>
<div
class="absolute inset-x-0 bottom-0 flex items-center gap-2.5 px-4 pb-3 transition-opacity duration-200"
style={{ opacity: shown() ? 1 : 0 }}
>
<svg
width="15"
height="15"
viewBox="0 0 16 16"
style={{ color: xdr() ? "#FFD23F" : "white" }}
>
<Show
when={pk().kind === "volume"}
fallback={
<g fill="currentColor" stroke="currentColor" stroke-width="1.3" stroke-linecap="round">
<circle cx="8" cy="8" r="3" stroke="none" />
<path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1" fill="none" />
</g>
}
>
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
<path d="M11 5.5a3.5 3.5 0 0 1 0 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
</Show>
</svg>
<div class="flex-1">
<p class="mb-1 text-left text-[9px] text-white/50">{pk().label}</p>
<div
class="relative h-[6px] rounded-full"
style={{ background: "rgba(255,255,255,0.12)" }}
>
<div
class="h-full rounded-full"
style={{
width: `${Math.min(level(), 1) * (pk().kind === "xdr" ? 76.9 : 100)}%`,
background: "white",
transition: "width 0.2s ease-out"
}}
/>
<Show when={pk().kind === "xdr"}>
<span
class="absolute top-[-3px] h-[12px] w-px bg-white/50"
style={{ left: "76.9%" }}
/>
<div
class="absolute top-0 h-full rounded-r-full"
style={{
left: "76.9%",
width: `${Math.max(0, level() - 1) * 76.9}%`,
background: "#FFD23F",
"box-shadow": "0 0 10px 2px rgba(255,210,63,0.6)",
transition: "width 0.2s ease-out"
}}
/>
</Show>
</div>
</div>
<span class="w-9 text-right font-mono text-[10px] text-white/80">
{Math.round(level() * 100)}%
</span>
</div>
</div>
</div>
);
}
/* ── Network: speed test on demand + identity rows ───────────────── */
function NetworkMock() {
const [down, setDown] = createSignal(0);
const [up, setUp] = createSignal(0);
const [running, setRunning] = createSignal(false);
const steps: [number, () => void][] = [
[0, () => (setDown(0), setUp(0), setRunning(false))],
[1200, () => setRunning(true)]
];
for (let k = 1; k <= 12; k++) {
steps.push([1200 + k * 120, () => setDown(Math.round((842 * k) / 12))]);
steps.push([2800 + k * 90, () => setUp(Math.round((96 * k) / 12))]);
}
steps.push([4000, () => setRunning(false)]);
useDemoLoop(8400, steps);
return (
<ModuleCard class="w-full max-w-[260px]">
<div class="space-y-2 text-left">
<div class="flex items-end gap-3">
<div>
<p class="text-[8px] font-semibold text-white/40">DOWN</p>
<p class="font-mono text-[16px] font-semibold text-white">
{down()}
<span class="ml-0.5 text-[9px] text-white/50">Mbps</span>
</p>
</div>
<div>
<p class="text-[8px] font-semibold text-white/40">UP</p>
<p class="font-mono text-[16px] font-semibold text-white">
{up()}
<span class="ml-0.5 text-[9px] text-white/50">Mbps</span>
</p>
</div>
<span
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white"
style={{ background: running() ? "rgba(255,255,255,0.1)" : ACCENT }}
>
{running() ? "testing…" : "Run"}
</span>
</div>
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
<For
each={[
["Public IP", "203.0.113.42"],
["ISP", "Example Fiber · AS64500"],
["DNS", "1.1.1.1"]
]}
>
{([k, v]) => (
<div class="flex items-center gap-2 text-[10px]">
<span class="w-14 text-white/40">{k}</span>
<span class="truncate font-mono text-white/80">{v}</span>
<span class="ml-auto text-white/30">⧉</span>
</div>
)}
</For>
<span
class="inline-block rounded-full px-2 py-0.5 text-[9px] font-semibold"
style={{ background: "rgba(110,167,255,0.18)", color: "#9cc3ff" }}
>
VPN · default route
</span>
</div>
</ModuleCard>
);
}
/* ── Disk: throughput trace, capacity, SMART ─────────────────────── */
function DiskMock() {
const tick = useTick(450);
const sample = (n: number, k: number) => {
const x = Math.sin(n * 0.9 + k * 2.1) * 0.5 + Math.sin(n * 0.37 + k) * 0.5;
return Math.max(0.05, Math.min(1, 0.45 + x * 0.45));
};
const path = (k: number) => {
const pts = Array.from({ length: 24 }, (_, i) => {
const v = sample(tick() + i, k) * (k === 0 ? 1 : 0.45);
return `${(i / 23) * 200},${40 - v * 36}`;
});
return `M${pts.join(" L")}`;
};
const read = () => Math.round(sample(tick() + 23, 0) * 900);
const write = () => Math.round(sample(tick() + 23, 1) * 0.45 * 900);
return (
<ModuleCard class="w-full max-w-[260px]">
<div class="space-y-2 text-left">
<div class="flex items-baseline gap-3 font-mono text-[11px]">
<span style={{ color: CALM }}>R {read()} MB/s</span>
<span style={{ color: WARM }}>W {write()} MB/s</span>
</div>
<svg viewBox="0 0 200 42" class="h-[42px] w-full" preserveAspectRatio="none">
<path d={path(0)} fill="none" stroke={CALM} stroke-width="1.5" />
<path d={path(1)} fill="none" stroke={WARM} stroke-width="1.5" />
</svg>
<div class="flex items-center text-[10px]">
<span class="text-white/70">Macintosh HD</span>
<span class="ml-auto text-white/45">612 GB free of 1 TB</span>
</div>
<div
class="h-[5px] overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div class="h-full rounded-full" style={{ width: "39%", background: CALM }} />
</div>
<span
class="inline-block rounded-full px-2 py-0.5 text-[9px] font-semibold"
style={{ background: "rgba(111,185,130,0.18)", color: STATUS.ready }}
>
SMART · Verified
</span>
</div>
</ModuleCard>
);
}
/* ── Keep Awake: four ways to hold off sleep ─────────────────────── */
const AWAKE_MODES = [
{ label: "1 hour", note: "awake for 59:12 more" },
{ label: "On power", note: "awake while plugged in" },
{ label: "Lid closed", note: "keeps running with the lid shut" },
{ label: "Agents", note: "awake only while agents work" }
];
function KeepAwakeMock() {
const [mode, setMode] = createSignal(0);
useDemoLoop(
9600,
AWAKE_MODES.map((_, n) => [n * 2400, () => setMode(n)] as [number, () => void])
);
return (
<ModuleCard class="w-full max-w-[260px]">
<div class="flex flex-col items-center gap-2.5">
<svg width="40" height="40" viewBox="0 0 40 40" class="overflow-visible">
<g class="nook-steam" stroke="rgba(255,255,255,0.5)" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M15 12c-2-3 2-4 0-7" />
<path d="M21 12c-2-3 2-4 0-7" style={{ "animation-delay": "-0.6s" }} />
</g>
<path d="M8 15h22v9a9 9 0 0 1-9 9h-4a9 9 0 0 1-9-9Z" fill={WARM} />
<path d="M30 18h2a4 4 0 0 1 0 8h-2" stroke={WARM} stroke-width="2.4" fill="none" />
</svg>
<div class="grid w-full grid-cols-2 gap-1.5">
<For each={AWAKE_MODES}>
{(m, n) => (
<span
class="rounded-md py-1 text-center text-[10px] font-semibold transition-colors duration-300"
style={{
background: mode() === n() ? ACCENT : CARD_FILL,
color: mode() === n() ? "white" : "rgba(255,255,255,0.55)",
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
}}
>
{m.label}
</span>
)}
</For>
</div>
<p class="text-[10px] text-white/45">{AWAKE_MODES[mode()].note}</p>
</div>
</ModuleCard>
);
}
/* ── Approval modes: Auto · Yolo · Verify ────────────────────────── */
const MODES = [
{ name: "Auto", note: "follows each agent's own settings", card: "asks", tint: "rgba(234,179,8,0.10)" },
{ name: "Yolo", note: "gated tools pass straight through", card: "passed", tint: "rgba(111,185,130,0.12)" },
{ name: "Verify", note: "every gated tool waits for you", card: "held", tint: "rgba(229,72,77,0.12)" }
];
function ApprovalModesMock() {
const [mode, setMode] = createSignal(0);
useDemoLoop(8400, [
[0, () => setMode(0)],
[2800, () => setMode(1)],
[5600, () => setMode(2)]
]);
const m = () => MODES[mode()];
return (
<div class="w-full max-w-[260px] space-y-2.5">
<div
class="relative flex rounded-lg p-0.5"
style={{ background: CARD_FILL, "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
>
<span
class="absolute top-0.5 bottom-0.5 rounded-md transition-all duration-300"
style={{
left: `calc(${mode() * 33.333}% + 2px)`,
width: "calc(33.333% - 4px)",
background: ACCENT
}}
/>
<For each={MODES}>
{(x, n) => (
<span
class="relative z-10 flex-1 py-1 text-center text-[11px] font-semibold transition-colors"
style={{ color: mode() === n() ? "white" : "rgba(255,255,255,0.55)" }}
>
{x.name}
</span>
)}
</For>
</div>
<div
class="rounded-[10px] p-2.5 text-left transition-colors duration-500"
style={{ background: m().tint }}
>
<div class="flex items-center gap-1.5">
<span class="text-[12px] font-medium text-white">Bash</span>
<span class="truncate font-mono text-[10px] text-white/50">
rm -rf build/
</span>
<span
class="ml-auto rounded-full px-1.5 py-0.5 text-[9px] font-semibold text-white"
style={{ background: "rgba(255,255,255,0.12)" }}
>
{m().card}
</span>
</div>
</div>
<p class="text-center text-[10px] text-white/45">{m().note}</p>
</div>
);
}
/* ── Lock Screen: counts without unlocking ───────────────────────── */
function LockScreenMock() {
const [counts, setCounts] = createSignal({ working: 3, waiting: 0 });
useDemoLoop(7200, [
[0, () => setCounts({ working: 3, waiting: 0 })],
[2400, () => setCounts({ working: 2, waiting: 1 })],
[4800, () => setCounts({ working: 1, waiting: 0 })]
]);
return (
<div
class="relative h-[150px] w-full max-w-[260px] overflow-hidden rounded-xl"
style={{
background: "radial-gradient(120% 90% at 30% 10%, #2b4a6b 0%, #191b2e 55%, #0d0d0f 100%)"
}}
>
<div
class="absolute top-0 left-1/2 flex h-[22px] -translate-x-1/2 items-center gap-2 rounded-b-[9px] bg-black px-2.5"
>
<span class="flex items-center gap-1">
<span class="h-[6px] w-[6px] rounded-[1.5px]" style={{ background: STATUS.running }} />
<span class="font-mono text-[9px] font-semibold text-white/90">{counts().working}</span>
</span>
<span class="w-6" />
<span class="flex items-center gap-1">
<span
class="h-[6px] w-[6px] rounded-[1.5px] transition-colors"
style={{ background: counts().waiting ? STATUS.attention : STATUS.idle }}
/>
<span class="font-mono text-[9px] font-semibold text-white/90">{counts().waiting}</span>
</span>
</div>
<div class="flex h-full flex-col items-center justify-center pt-4">
<p class="text-[34px] leading-tight font-semibold tracking-tight text-white/90">
9:41
</p>
<span class="mt-2 h-5 w-5 rounded-full border border-white/30" />
</div>
</div>
);
}
/* ── Layout: the mosaic reflows ──────────────────────────────────── */
type Box = [l: number, t: number, w: number, h: number];
const LAYOUTS: Box[][] = [
[[0, 0, 66, 58], [68, 0, 32, 58], [0, 62, 32, 38], [34, 62, 32, 38], [68, 62, 32, 38]],
[[0, 0, 32, 100], [34, 0, 66, 40], [34, 44, 32, 56], [68, 44, 32, 26], [68, 74, 32, 26]],
[[0, 0, 49, 48], [51, 0, 49, 48], [0, 52, 100, 22], [0, 78, 49, 22], [51, 78, 49, 22]]
];
const TILE_COLORS = [CALM, STATUS.ready, WARM, "#fa5c7c", "rgba(255,255,255,0.5)"];
function LayoutMock() {
const [n, setN] = createSignal(0);
useDemoLoop(7500, [
[0, () => setN(0)],
[2500, () => setN(1)],
[5000, () => setN(2)]
]);
return (
<div
class="relative h-[130px] w-full max-w-[240px] rounded-[12px] bg-black p-2"
style={{ "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
>
<div class="relative h-full w-full">
<For each={TILE_COLORS}>
{(color, i) => {
const b = () => LAYOUTS[n()][i()];
return (
<div
class="absolute rounded-md"
style={{
left: `${b()[0]}%`,
top: `${b()[1]}%`,
width: `${b()[2]}%`,
height: `${b()[3]}%`,
background: CARD_FILL,
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}, inset 3px 0 0 ${color}`,
transition: "all 0.7s cubic-bezier(0.34,1.3,0.64,1)"
}}
/>
);
}}
</For>
</div>
</div>
);
}
/* ── Marquee ─────────────────────────────────────────────────────── */
const CHIPS_A = [
"18 agents",
"Custom agents",
"Six chiptunes",
"Focus-aware slots",
"Per-app output routing",
"Low Power Mode toggle",
"Reminders",
"Battery health",
"Energy flow in watts",
"Thermal charge protection",
"Top-up charging",
"Memory by process"
];
const CHIPS_B = [
"Jump to the terminal",
"tmux · iTerm2 · kitty",
"Markdown replies",
"Tailscale hosts",
"iPhone widgets",
"Watch-face widgets",
"Screenshots to clipboard",
"System audio + mic",
"Hourly forecast",
"External display brightness",
"Lid-closed keep-awake"
];
function MarqueeRow(props: { chips: string[]; reverse?: boolean }) {
return (
<div class="nook-marquee">
<div class={`nook-marquee-track ${props.reverse ? "reverse" : ""}`}>
<For each={[...props.chips, ...props.chips]}>
{(chip, i) => (
<span
aria-hidden={i() >= props.chips.length ? "true" : undefined}
class="border-overlay0 bg-surface0 text-subtext0 rounded-full border px-3.5 py-1.5 text-xs font-semibold whitespace-nowrap"
>
{chip}
</span>
)}
</For>
</div>
</div>
);
}
export default function IslandExtras() {
return (
<section class="bg-base relative z-20 py-16" id="island-extras">
<div class="px-4 md:px-8">
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
And everything else
</p>
<h2 class="text-text mb-3 text-center text-3xl font-bold">
One island instead of a menu bar full of utilities
</h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl text-center leading-relaxed">
Every module below lives in the same notch, arranged however you
like, under the same one-time license.
</p>
</div>
<div class="mb-12 space-y-3">
<MarqueeRow chips={CHIPS_A} />
<MarqueeRow chips={CHIPS_B} reverse />
</div>
<div class="mx-auto grid max-w-5xl grid-cols-1 gap-5 px-4 md:grid-cols-3 md:px-8">
<CollageCell
class="md:col-span-2"
kicker="Now Playing"
title="Music in the notch"
body="Track, source, and transport controls in a module. Sources without artwork, like some browsers, get a live waveform in the slot instead."
>
<InkStage class="h-full w-full items-center">
<NowPlayingMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Timers"
title="Timers that stack"
body="A readout over a tick tape that drains with a soft crest. Run several at once; finished timers ring and hold the island open until you dismiss them."
>
<InkStage class="h-full w-full items-center px-3">
<TimersMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Weather"
title="The sky, in the slot"
body="Saved places with hourly and daily forecasts. The compact slot stacks the sky over the temperature."
>
<InkStage class="h-full w-full items-center px-3">
<WeatherMock />
</InkStage>
</CollageCell>
<CollageCell
class="md:col-span-2"
kicker="Interceptions"
title="Volume and brightness, under the notch"
body="An optional takeover of the system HUD: levels peek out below the island. Extra brightness spends an XDR panel's HDR headroom past the usual maximum, and external displays get real brightness control."
>
<InkStage class="h-full w-full items-center">
<div class="flex min-h-[110px] w-full justify-center pt-2">
<InterceptionMock />
</div>
</InkStage>
</CollageCell>
<CollageCell
kicker="Approval modes"
title="One stance for every agent"
body="Auto follows each agent's own settings, Yolo waves gated tools through, and Verify holds every one until you decide."
>
<InkStage class="h-full w-full items-center px-3">
<ApprovalModesMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Network"
title="Know your connection"
body="Public IP, ISP, gateway, and DNS one click from your clipboard. VPN routes get flagged, and a speed test is always a click away."
>
<InkStage class="h-full w-full items-center px-3">
<NetworkMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Disks"
title="Storage, measured"
body="Live read and write throughput, capacity that matches Finder, and SMART health for your drives."
>
<InkStage class="h-full w-full items-center px-3">
<DiskMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Keep Awake"
title="Sleep when you say"
body="Stay awake for a set time, while plugged in, with the lid closed, or only while your agents are working."
>
<InkStage class="h-full w-full items-center px-3">
<KeepAwakeMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Lock Screen"
title="Visible while locked"
body="The island stays on your Lock Screen, so you can see how many agents are working or waiting without unlocking."
>
<InkStage class="h-full w-full items-center px-3">
<LockScreenMock />
</InkStage>
</CollageCell>
<CollageCell
kicker="Layout"
title="Panels you shape"
body="Drag and resize modules in the mosaic editor, pick a density, and let slot rankings switch with your Focus mode."
>
<InkStage class="h-full w-full items-center px-3">
<LayoutMock />
</InkStage>
</CollageCell>
</div>
</section>
);
}

View File

@@ -85,9 +85,10 @@ function IslandSurface(props: { children: JSX.Element; class?: string }) {
onMount(() => {
const node = el;
if (!node) return;
// Layout size, not getBoundingClientRect: a scaled-down mock (the
// Capture demo's island row) would otherwise clip itself to a corner.
const apply = () => {
const r = node.getBoundingClientRect();
node.style.clipPath = `path("${islandPath(r.width, r.height)}")`;
node.style.clipPath = `path("${islandPath(node.offsetWidth, node.offsetHeight)}")`;
};
apply();
const observer = new ResizeObserver(apply);
@@ -261,3 +262,34 @@ export function IslandExpanded(props: {
</div>
);
}
/** Fake cursor dot: rests on a target, presses, exits. */
export function FakeCursor(props: {
phase: "idle" | "press" | "live";
at: { x: number; y: number };
exit?: { x: number; y: number };
}) {
const p = () =>
props.phase === "press"
? { ...props.at, scale: 0.62, o: 1 }
: props.phase === "idle"
? { ...props.at, scale: 1, o: 1 }
: { ...(props.exit ?? { x: 106, y: 8 }), scale: 1, o: 0 };
return (
<span
class="pointer-events-none absolute z-10 rounded-full bg-white"
style={{
width: "9px",
height: "9px",
"box-shadow":
"0 1px 4px rgba(0,0,0,0.6), 0 0 0 4px rgba(255,255,255,0.18)",
left: `${p().x}%`,
top: `${p().y}%`,
opacity: p().o,
transform: `translate(-50%, -50%) scale(${p().scale})`,
transition:
"left 0.8s cubic-bezier(0.35,0,0.25,1), top 0.8s cubic-bezier(0.35,0,0.25,1), opacity 0.4s, transform 0.3s"
}}
/>
);
}

View File

@@ -0,0 +1,405 @@
import { For, Show, type JSX } from "solid-js";
import { STATUS, ACCENT, CALM, WARM, CARD_FILL, CARD_STROKE } from "./IslandMock";
/**
* Module-level mocks rebuilt from the app's SwiftUI views, so the site shows
* the same anatomy the island does: the session row (SessionRowView), the
* approval card (AgentsActionCards), the timer tape (TimerTape), a
* temperature line (TemperaturesPanelView), and the Wings (WingPanels).
*/
/* ── Glyphs: the SF Symbols the mocked views use ─────────────────── */
/* Factories, not shared nodes: a Solid JSX node can mount in only one place. */
const GLYPHS: Record<string, () => JSX.Element> = {
speaker: () => (
<>
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
<path d="M11 5.5a3.5 3.5 0 0 1 0 5M12.8 3.6a6 6 0 0 1 0 8.8" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
</>
),
speakerOff: () => (
<>
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
<path d="m11 6 4 4m0-4-4 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
</>
),
mic: () => (
<>
<rect x="5.5" y="1.5" width="5" height="8.5" rx="2.5" fill="currentColor" />
<path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
</>
),
micOff: () => (
<>
<rect x="5.5" y="1.5" width="5" height="8.5" rx="2.5" fill="currentColor" opacity="0.9" />
<path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5M2.5 2.5l11 11" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
</>
),
pause: () => (
<>
<rect x="4" y="3" width="3" height="10" rx="1" fill="currentColor" />
<rect x="9" y="3" width="3" height="10" rx="1" fill="currentColor" />
</>
),
play: () => <path d="M5 3v10l8-5Z" fill="currentColor" />,
stop: () => <rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />,
viewfinder: () => (
<path d="M2 5V3.5A1.5 1.5 0 0 1 3.5 2H5m6 0h1.5A1.5 1.5 0 0 1 14 3.5V5m0 6v1.5a1.5 1.5 0 0 1-1.5 1.5H11m-6 0H3.5A1.5 1.5 0 0 1 2 12.5V11m6-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" />
),
record: () => (
<>
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3" fill="none" />
<circle cx="8" cy="8" r="3" fill="currentColor" />
</>
),
dashed: () => (
<rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" stroke-width="1.3" stroke-dasharray="2 1.6" fill="none" />
),
display: () => (
<path d="M2.5 3h11a1 1 0 0 1 1 1v6.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm3 11h5M8 11.5V14" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" />
),
window: () => (
<>
<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="currentColor" stroke-width="1.3" fill="none" />
<path d="M1.5 6h13" stroke="currentColor" stroke-width="1.3" />
</>
),
chevron: () => <path d="m4.5 6.5 3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round" />,
film: () => (
<path d="M3 2.5h10a.5.5 0 0 1 .5.5v10a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5Zm2 0v11m6-11v11M2.5 6h2.5m6 0h2.5m-11 4h2.5m6 0h2.5" stroke="currentColor" stroke-width="1.2" fill="none" />
),
xmark: () => <path d="m4 4 8 8m0-8-8 8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />,
bell: () => (
<path d="M8 1.8a4.2 4.2 0 0 0-4.2 4.2v3L2.5 11.3h11L12.2 9V6A4.2 4.2 0 0 0 8 1.8ZM6.3 12.8a1.8 1.8 0 0 0 3.4 0Z" fill="currentColor" />
),
restart: () => (
<path d="M3.5 8a4.5 4.5 0 1 0 1.4-3.3M3.2 2.2v2.8H6" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
),
plus: () => <path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />,
refresh: () => (
<path d="M12.5 8A4.5 4.5 0 1 1 11 4.6M11.6 2v2.8H8.8" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
)
};
export type GlyphName = keyof typeof GLYPHS;
export function Glyph(props: { name: GlyphName; size?: number; class?: string; style?: JSX.CSSProperties }) {
return (
<svg
width={props.size ?? 12}
height={props.size ?? 12}
viewBox="0 0 16 16"
class={`shrink-0 ${props.class ?? ""}`}
style={props.style}
aria-hidden="true"
>
{GLYPHS[props.name]()}
</svg>
);
}
/* ── Agents ──────────────────────────────────────────────────────── */
export interface AgentLook {
name: string;
/** brandColorHex from the app's AgentRegistry row. */
color: string;
/** The brand mark the app bundles (AgentBrands/*.svg). */
icon: string;
}
export const CLAUDE: AgentLook = {
name: "Claude Code",
color: "#D97742",
icon: "/nook/brands/claude.svg"
};
export const CODEX: AgentLook = {
name: "Codex",
color: "#4AA3DF",
icon: "/nook/brands/codex.svg"
};
/** A row badge: brand-tinted for the agent chip, neutral otherwise. */
function Badge(props: { text: string; tint?: string }) {
return (
<span
class="max-w-full truncate rounded-full px-[7px] py-[2px] text-[10px] font-semibold whitespace-nowrap"
style={{
color: props.tint ?? "rgba(255,255,255,0.75)",
background: props.tint ? `${props.tint}33` : "rgba(255,255,255,0.08)"
}}
>
{props.text}
</span>
);
}
/**
* SessionRowView: "project · branch", the latest prompt, a status-colored
* state line; on the right the agent chip (named "Agent · host" for a
* remote session), the terminal origin, the status dot and age.
*/
export function SessionRowMock(props: {
title: string;
prompt: string;
state: string;
status: keyof typeof STATUS;
agent: AgentLook;
/** Remote sessions name their machine in the agent chip. */
host?: string;
origin?: string;
age: string;
}) {
return (
<div
class="@container rounded-xl px-2 py-2.5 text-left"
style={{ background: CARD_FILL, "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
>
{/* The app's layout (badges in a right-hand third) wherever the row
is wide enough; narrow site cells stack the badges beneath. */}
<div class="flex flex-col gap-1.5 @[340px]:flex-row @[340px]:items-start @[340px]:gap-2.5">
<div class="min-w-0 flex-1 space-y-[3px]">
<p class="truncate text-[13px] font-semibold text-white">{props.title}</p>
<p class="truncate text-[11.5px] text-white/55">User: {props.prompt}</p>
<p
class="truncate text-[11.5px] font-medium transition-colors duration-500"
style={{ color: STATUS[props.status] }}
>
{props.state}
</p>
</div>
<div class="flex min-w-0 shrink-0 items-start gap-1.5 @[340px]:max-w-[50%]">
<div class="flex min-w-0 flex-col items-center">
<Badge
text={props.host ? `${props.agent.name} · ${props.host}` : props.agent.name}
tint={props.agent.color}
/>
</div>
<div class="flex items-center gap-1.5 @[340px]:flex-col @[340px]:items-end @[340px]:gap-0.5">
<Show when={props.origin}>
<Badge text={props.origin!} />
</Show>
<span class="flex items-center gap-1.5">
<span
class="h-[7px] w-[7px] rounded-full transition-colors duration-500"
style={{ background: STATUS[props.status] }}
/>
<span class="rounded-full bg-white/[0.08] px-[7px] py-[2px] text-[10px] text-white/80">
{props.age}
</span>
</span>
</div>
</div>
</div>
</div>
);
}
/** SwiftUI's dark-mode .yellow, which the approval card tints with. */
const YELLOW = "255,214,10";
/**
* ApprovalCard: brand mark, session title, "approval" capsule; "Run <tool>",
* the input in mono; Allow (accent, the island's tint), Allow for session,
* Deny, and Deny with reason….
*/
export function ApprovalCardMock(props: {
agent: AgentLook;
title: string;
tool: string;
input: string;
pressed?: "allow" | "session" | "deny";
}) {
const button = (key: "allow" | "session" | "deny", label: string, prominent = false) => (
<span
class="rounded-md px-2 py-[3px] text-[11px] font-medium whitespace-nowrap text-white transition-transform duration-150"
style={{
background: prominent ? ACCENT : "rgba(255,255,255,0.12)",
transform: props.pressed === key ? "scale(0.92)" : "scale(1)",
filter: props.pressed === key ? "brightness(1.25)" : "none"
}}
>
{label}
</span>
);
return (
<div class="rounded-[10px] p-2.5 text-left" style={{ background: `rgba(${YELLOW},0.08)` }}>
<div class="flex items-center gap-1.5">
<img src={props.agent.icon} alt="" class="h-[13px] w-[13px]" />
<span class="truncate text-[12px] font-semibold text-white">{props.title}</span>
<span
class="ml-auto shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{ background: `rgba(${YELLOW},0.25)` }}
>
approval
</span>
</div>
<p class="mt-2 text-[13px] font-medium text-white">Run {props.tool}</p>
<p class="mt-0.5 truncate font-mono text-[11px] text-white/50">{props.input}</p>
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{button("allow", "Allow", true)}
{button("session", "Allow for session")}
{button("deny", "Deny")}
<span class="text-[11px] whitespace-nowrap text-white/50">Deny with reason…</span>
</div>
</div>
);
}
/* ── Timer tape ──────────────────────────────────────────────────── */
/** TimerPalette.accent. */
export const TIMER_ORANGE = "#FA9E4D";
/**
* TimerTapeDrawing.ticks, ported: fixed-pitch 1.5 px ticks resting at 40 %
* of the tape height, swelling to a 72 % crest (raised cosine) that leads
* into the remaining side and trails a shorter shoulder behind.
*/
function tapeTicks(remaining: number, w: number, h: number) {
const span = w - 2;
const count = Math.floor(span / 6) + 1;
const pitch = span / Math.max(1, count - 1);
const boundary = 1 + span * remaining;
const baseline = h * 0.4;
const peak = h * 0.72;
const lead = Math.min(84, w * 0.4);
const trail = lead * 0.5;
const swell = (d: number) => (d < 1 ? (1 + Math.cos(Math.PI * d)) / 2 : 0);
return Array.from({ length: count }, (_, i) => {
const cx = 1 + i * pitch;
const offset = boundary - cx;
const rise = remaining > 0 ? swell(Math.abs(offset) / (offset >= 0 ? lead : trail)) : 0;
const height = baseline + (peak - baseline) * rise;
const fill = remaining > 0 ? Math.min(1, Math.max(0, offset / pitch + 1)) : 0;
return { x: cx - 0.75, y: (h - height) / 2, height, fill };
});
}
export function TimerTapeMock(props: {
remaining: number;
width: number;
height: number;
color?: string;
opacity?: number;
}) {
return (
<svg
viewBox={`0 0 ${props.width} ${props.height}`}
width={props.width}
height={props.height}
class="block max-w-full"
style={{ opacity: props.opacity ?? 1 }}
aria-hidden="true"
>
<For each={tapeTicks(Math.min(1, Math.max(0, props.remaining)), props.width, props.height)}>
{(t) => (
<>
<Show when={t.fill < 1}>
<rect x={t.x} y={t.y} width="1.5" height={t.height} rx="0.75" fill="rgba(255,255,255,0.18)" />
</Show>
<Show when={t.fill > 0}>
<rect
x={t.x}
y={t.y}
width="1.5"
height={t.height}
rx="0.75"
fill={props.color ?? TIMER_ORANGE}
opacity={t.fill}
/>
</Show>
</>
)}
</For>
</svg>
);
}
/* ── Temperatures ────────────────────────────────────────────────── */
/** MetricPalette.usage: calm below 70 %, warm to 90 %, critical above. */
function usageColor(fraction: number) {
if (fraction >= 0.9) return "rgba(229,72,77,0.9)";
if (fraction >= 0.7) return WARM;
return CALM;
}
/** Tier color at the app's default thresholds (warn 70 °C, alert 85 °C). */
function tierColor(celsius: number) {
if (celsius >= 85) return "rgba(229,72,77,0.9)";
if (celsius >= 70) return WARM;
return "white";
}
/**
* One die line: label, its 60 s usage trace (filled at 20 %, 1.5 px line,
* over a hairline floor), and the temperature in mono, tier-tinted.
*/
export function DieLine(props: { label: string; usage: number[]; celsius: number }) {
const path = () => {
const n = props.usage.length;
return props.usage
.map((u, i) => `${i === 0 ? "M" : "L"}${((i / (n - 1)) * 100).toFixed(2)},${(26 - u * 26).toFixed(2)}`)
.join(" ");
};
const color = () => usageColor(props.usage[props.usage.length - 1] ?? 0);
return (
<div class="flex items-center gap-2">
<span class="text-[9px] font-semibold text-white/50">{props.label}</span>
<svg viewBox="0 0 100 26" preserveAspectRatio="none" class="h-[26px] min-w-0 flex-1" aria-hidden="true">
<path d="M0,25.5 L100,25.5" stroke="rgba(255,255,255,0.08)" stroke-width="1" vector-effect="non-scaling-stroke" />
<path d={`${path()} L100,26 L0,26 Z`} fill={color()} fill-opacity="0.2" />
<path d={path()} fill="none" stroke={color()} stroke-width="1.5" vector-effect="non-scaling-stroke" />
</svg>
<span
class="font-mono text-[15px] font-medium transition-colors duration-500"
style={{ color: tierColor(props.celsius) }}
>
{Math.round(props.celsius)}°
</span>
</div>
);
}
/* ── Wings ───────────────────────────────────────────────────────── */
export interface WingControlMock {
glyph: GlyphName;
on?: boolean;
pressed?: boolean;
}
/**
* One edge's capsule: black at 94 %, a 1 px white-18 % stroke, and each
* control a circle as tall as the Compact island, icon at 42 %.
*/
export function WingCapsule(props: { controls: WingControlMock[]; height: number }) {
return (
<div
class="flex rounded-full"
style={{
height: `${props.height}px`,
background: "rgba(0,0,0,0.94)",
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.18)"
}}
>
<For each={props.controls}>
{(c) => (
<span
class="flex items-center justify-center rounded-full transition-all duration-200"
style={{
width: `${props.height}px`,
height: `${props.height}px`,
color: `rgba(255,255,255,${c.on === false ? 0.45 : 1})`,
background: c.pressed ? "rgba(255,255,255,0.14)" : "transparent"
}}
>
<Glyph name={c.glyph} size={Math.round(props.height * 0.42)} />
</span>
)}
</For>
</div>
);
}

View File

@@ -0,0 +1,617 @@
import { For, Show, createSignal, type JSX } from "solid-js";
import {
ModuleCard,
FakeCursor,
IslandPill,
AgentDotGrid,
STATUS,
ACCENT,
CALM,
CRITICAL,
CARD_STROKE
} from "./IslandMock";
import { useDemoLoop } from "./useDemoLoop";
import { Glyph, WingCapsule, type GlyphName } from "./ModuleMocks";
/**
* Live demos for the breakdowns added with the 0.6–0.10 feature set:
* terminal replies, the volume mixer, Capture (module + Wings), and
* floating actions. Same ink chrome and status palette as the originals.
*/
/* ── Reply lands in the agent's own terminal ──────────────────────── */
const REPLY = "yes — and run them";
export function TerminalReplyDemo() {
const [typed, setTyped] = createSignal(0);
const [phase, setPhase] = createSignal<
"asking" | "typing" | "sent" | "working" | "jump"
>("asking");
useDemoLoop(10000, [
[0, () => (setTyped(0), setPhase("asking"))],
[900, () => setPhase("typing")],
...REPLY.split("").map(
(_, i) => [1000 + i * 70, () => setTyped(i + 1)] as [number, () => void]
),
[2900, () => setPhase("sent")],
[3300, () => setPhase("working")],
[6600, () => setPhase("jump")]
]);
const delivered = () => phase() === "working" || phase() === "jump";
const cursorPhase = () =>
phase() === "sent" || phase() === "jump"
? ("press" as const)
: phase() === "typing" || phase() === "asking"
? ("idle" as const)
: ("live" as const);
const cursorAt = () =>
phase() === "jump" ? { x: 88, y: 14 } : { x: 88, y: 80 };
return (
<div class="mx-auto flex w-full max-w-[340px] flex-col gap-3">
<ModuleCard class="relative">
<div class="space-y-2 text-left">
<div class="flex items-center gap-2">
<span
class="inline-block h-[7px] w-[7px] rounded-full transition-colors duration-500"
style={{
background: delivered() ? STATUS.running : STATUS.ready
}}
/>
<span class="text-[13px] font-semibold text-white">the-nook</span>
<span class="font-mono text-[10px] text-white/40">main</span>
<span
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white/80 transition-colors duration-300"
style={{
background:
phase() === "jump" ? ACCENT : "rgba(255,255,255,0.08)"
}}
>
Jump ↗
</span>
</div>
<p class="text-[11px] leading-snug text-white/60">
{delivered()
? "Running the test suite…"
: "Done. Want me to update the tests too?"}
</p>
<div
class="flex items-center gap-2 rounded-lg px-2 py-1.5"
style={{ background: "rgba(255,255,255,0.06)" }}
>
<span class="min-h-[14px] flex-1 truncate font-mono text-[11px] text-white/85">
{delivered() ? "" : REPLY.slice(0, typed())}
<Show when={phase() === "typing"}>
<span class="nook-caret">▍</span>
</Show>
<Show when={!typed() || delivered()}>
<span class="text-white/30">Reply…</span>
</Show>
</span>
<span
class="rounded-md px-2 py-0.5 text-[10px] font-semibold text-white transition-transform duration-200"
style={{
background: ACCENT,
transform: phase() === "sent" ? "scale(0.9)" : "scale(1)"
}}
>
Send
</span>
</div>
</div>
<FakeCursor phase={cursorPhase()} at={cursorAt()} />
</ModuleCard>
{/* the terminal the session actually runs in */}
<div
class="overflow-hidden rounded-lg text-left transition-shadow duration-500"
style={{
background: "#16161a",
"box-shadow":
phase() === "jump"
? `0 0 0 2px ${ACCENT}, 0 8px 30px rgba(72,151,178,0.35)`
: `inset 0 0 0 1px ${CARD_STROKE}`
}}
>
<div class="flex items-center gap-1.5 px-2.5 py-1.5">
<span class="h-2 w-2 rounded-full bg-[#ff5f57]" />
<span class="h-2 w-2 rounded-full bg-[#febc2e]" />
<span class="h-2 w-2 rounded-full bg-[#28c840]" />
<span class="ml-2 font-mono text-[10px] text-white/40">
tmux · the-nook:2
</span>
<span
class="ml-auto text-[9px] font-semibold tracking-wide uppercase transition-opacity duration-300"
style={{ color: ACCENT, opacity: phase() === "jump" ? 1 : 0 }}
>
focused
</span>
</div>
<div class="space-y-0.5 px-2.5 pb-2.5 font-mono text-[10.5px] leading-relaxed">
<p class="text-white/40">⏺ Updated 3 files</p>
<p class="text-white/70">
&gt;{" "}
<span
class="transition-opacity duration-300"
style={{ opacity: delivered() ? 1 : 0 }}
>
{REPLY}
</span>
</p>
<p
class="transition-opacity duration-500"
style={{ color: CALM, opacity: delivered() ? 1 : 0 }}
>
⏺ Bash(swift test)
</p>
</div>
</div>
</div>
);
}
/* ── Volume mixer: every app its own level and output ─────────────── */
interface MixerApp {
name: string;
color: string;
glyph: string;
}
const MIXER_APPS: MixerApp[] = [
{ name: "Music", color: "#fa5c7c", glyph: "♫" },
{ name: "Browser", color: "#6EA7FF", glyph: "◎" },
{ name: "Meeting", color: "#6FB982", glyph: "●" },
{ name: "podcast-cli", color: "#E7A762", glyph: ">_" }
];
export function MixerDemo() {
const [step, setStep] = createSignal(0);
useDemoLoop(9600, [
[0, () => setStep(0)],
[1800, () => setStep(1)],
[3800, () => setStep(2)],
[5800, () => setStep(3)]
]);
const level = (i: number) => {
if (i === 0) return step() >= 3 ? 0.38 : 0.72;
if (i === 1) return 0.55;
if (i === 2) return 0.86;
return 0.46;
};
const muted = (i: number) => i === 1 && step() >= 1;
const route = (i: number) =>
i === 0 && step() >= 2 ? "Headphones" : i === 2 ? "Display" : "Speakers";
return (
<ModuleCard class="mx-auto w-full max-w-[340px]">
<div class="space-y-2.5 text-left">
<div class="flex items-center">
<span class="text-[11px] font-semibold text-white/50">
Volume mixer
</span>
<span class="ml-auto font-mono text-[10px] text-white/40">
out · Speakers
</span>
</div>
<For each={MIXER_APPS}>
{(app, i) => (
<div class="flex items-center gap-2">
<span
class="flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px] text-[8px] font-bold text-black/70"
style={{ background: app.color }}
>
{app.glyph}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="truncate text-[11px] font-semibold text-white/85">
{app.name}
</span>
<span
class="ml-auto shrink-0 rounded px-1.5 text-[9px] text-white/55 transition-colors duration-500"
style={{
background:
i() === 0 && step() === 2
? `${ACCENT}55`
: "rgba(255,255,255,0.07)"
}}
>
{route(i())}
</span>
</div>
<div
class="relative mt-1 h-[5px] overflow-hidden rounded-full"
style={{ background: "rgba(255,255,255,0.08)" }}
>
<div
class="h-full rounded-full"
style={{
width: `${level(i()) * 100}%`,
background: muted(i()) ? "rgba(255,255,255,0.18)" : CALM,
transition: "width 0.9s cubic-bezier(0.22,1,0.36,1), background 0.3s"
}}
/>
{/* live signal riding inside the level */}
<div
class="nook-meter absolute top-0 left-0 h-full rounded-full"
style={{
width: `${level(i()) * 100}%`,
background: "rgba(255,255,255,0.35)",
opacity: muted(i()) ? 0 : 1,
"animation-duration": `${0.5 + i() * 0.17}s`,
transition: "width 0.9s cubic-bezier(0.22,1,0.36,1), opacity 0.3s"
}}
/>
</div>
</div>
<span
class="w-4 shrink-0 text-center text-[11px] transition-colors duration-300"
style={{ color: muted(i()) ? CRITICAL : "rgba(255,255,255,0.4)" }}
>
{muted(i()) ? "✕" : "♪"}
</span>
</div>
)}
</For>
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
<p class="text-[10px] text-white/40">
{
[
"every app on its own fader",
"browser muted — the rest keep playing",
"music routed to headphones, meeting stays on the display",
"music down, nothing else moved"
][step()]
}
</p>
</div>
</ModuleCard>
);
}
/* ── Capture: the module, and the Wings while a recording runs ────── */
/** CapturePalette.recording and the NookPalette paper/ink pair. */
const RECORDING_RED = "rgb(242,69,69)";
const PAPER = "#f1ead9";
const INK = "#0d0d0f";
function CaptureChip(props: {
glyph: GlyphName;
label: string;
menu?: boolean;
pressed?: boolean;
}) {
return (
<span
class="flex flex-1 items-center justify-center gap-1 rounded-full py-1.5 text-[11px] font-medium whitespace-nowrap text-white transition-transform duration-150"
style={{
background: props.pressed ? "rgba(255,255,255,0.16)" : "rgba(255,255,255,0.08)",
transform: props.pressed ? "scale(0.94)" : "scale(1)"
}}
>
<Glyph name={props.glyph} size={11} />
{props.label}
<Show when={props.menu}>
<Glyph name="chevron" size={8} class="opacity-50" />
</Show>
</span>
);
}
/** CaptureAudioToggles: paper capsule when on, faint when off. */
function AudioToggles(props: { system: boolean; mic: boolean }) {
const toggle = (on: boolean, glyph: GlyphName, label: string) => (
<span
class="flex items-center gap-1 rounded-full px-2 py-1 text-[10.5px] font-medium whitespace-nowrap transition-colors duration-300"
style={{
background: on ? PAPER : "rgba(255,255,255,0.08)",
color: on ? INK : "rgba(255,255,255,0.7)"
}}
>
<Glyph name={glyph} size={10} />
{label}
</span>
);
return (
<div class="flex gap-1.5">
{toggle(props.system, props.system ? "speaker" : "speakerOff", "System audio")}
{toggle(props.mic, props.mic ? "mic" : "micOff", "Microphone")}
</div>
);
}
function RowLabel(props: { glyph: GlyphName; label: string }) {
return (
<p class="flex items-center gap-1 text-[11px] font-semibold text-white/50">
<Glyph name={props.glyph} size={11} />
{props.label}
</p>
);
}
export function CaptureDemo() {
const [phase, setPhase] = createSignal<"idle" | "press" | "rec" | "paused" | "saved">("idle");
const [secs, setSecs] = createSignal(0);
const [mic, setMic] = createSignal(true);
const [wingPress, setWingPress] = createSignal<"" | "mic" | "pause" | "stop">("");
useDemoLoop(12400, [
[0, () => (setPhase("idle"), setSecs(0), setMic(true), setWingPress(""))],
[1500, () => setPhase("press")],
[1800, () => setPhase("rec")],
...[1, 2, 3, 4, 5].map((s) => [1800 + s * 1000, () => setSecs(s)] as [number, () => void]),
[4300, () => setWingPress("mic")],
[4550, () => (setMic(false), setWingPress(""))],
[7100, () => setWingPress("pause")],
[7350, () => (setPhase("paused"), setWingPress(""))],
[9000, () => setWingPress("stop")],
[9250, () => (setPhase("saved"), setWingPress(""))]
]);
const recording = () => phase() === "rec" || phase() === "paused";
const paused = () => phase() === "paused";
/* Island row is drawn at half size; Wing controls are as tall as it. */
const wingWidth = 2 * 60 + 12;
const cursor = () => {
switch (wingPress()) {
case "mic":
return { phase: "press" as const, at: { x: 21.8, y: 7 } };
case "pause":
return { phase: "press" as const, at: { x: 78.2, y: 7 } };
case "stop":
return { phase: "press" as const, at: { x: 86.5, y: 7 } };
}
if (phase() === "idle" || phase() === "press")
return { phase: phase() === "press" ? ("press" as const) : ("idle" as const), at: { x: 50, y: 58 } };
if (phase() === "saved") return { phase: "live" as const, at: { x: 86.5, y: 7 } };
return { phase: "idle" as const, at: { x: 21.8, y: 30 } };
};
return (
<div class="relative mx-auto w-full max-w-[360px]">
{/* the Compact island, flanked by the Wings while recording */}
<div class="relative h-[30px]">
<div
class="absolute top-0 left-1/2 flex items-start"
style={{ transform: "translateX(-50%) scale(0.5)", "transform-origin": "top center" }}
>
<div
class="flex justify-end overflow-hidden transition-all duration-500"
style={{ width: recording() ? `${wingWidth}px` : "0px", opacity: recording() ? 1 : 0 }}
>
<WingCapsule
height={60}
controls={[
{ glyph: "speaker" },
{ glyph: mic() ? "mic" : "micOff", on: mic(), pressed: wingPress() === "mic" }
]}
/>
<span class="w-3 shrink-0" />
</div>
<IslandPill
campfire="running"
right={<AgentDotGrid states={["running", "ready", "running"]} />}
/>
<div
class="flex overflow-hidden transition-all duration-500"
style={{ width: recording() ? `${wingWidth}px` : "0px", opacity: recording() ? 1 : 0 }}
>
<span class="w-3 shrink-0" />
<WingCapsule
height={60}
controls={[
{ glyph: paused() ? "play" : "pause", pressed: wingPress() === "pause" },
{ glyph: "stop", pressed: wingPress() === "stop" }
]}
/>
</div>
</div>
</div>
{/* the Capture module */}
<ModuleCard class="mt-5">
<div class="min-h-[150px] text-left">
<Show
when={recording()}
fallback={
<div class="space-y-2.5">
<div class="space-y-1.5">
<RowLabel glyph="viewfinder" label="Screenshot" />
<div class="flex gap-1.5">
<CaptureChip glyph="dashed" label="Area" />
<CaptureChip glyph="display" label="Screen" />
<CaptureChip glyph="window" label="App" menu />
</div>
</div>
<div class="space-y-1.5">
<RowLabel glyph="record" label="Record" />
<div class="flex gap-1.5">
<CaptureChip glyph="dashed" label="Area" />
<CaptureChip glyph="display" label="Screen" pressed={phase() === "press"} />
<CaptureChip glyph="window" label="App" menu />
</div>
<AudioToggles system mic={mic()} />
</div>
<Show when={phase() === "saved"}>
<div class="flex items-center gap-1.5 text-[10.5px]">
<Glyph name="film" size={11} class="text-white/50" />
<span class="min-w-0 flex-1 truncate text-white/85">
Screen Recording 2026-10-13 at 9.41.12.mov
</span>
<span class="shrink-0 text-white/50">Show in Finder</span>
</div>
</Show>
</div>
}
>
<div class="space-y-2.5">
<div class="flex items-center gap-2">
<span
class="h-2 w-2 rounded-full transition-colors"
style={{ background: paused() ? "rgba(255,255,255,0.4)" : RECORDING_RED }}
/>
<span
class="font-mono text-[22px] font-semibold transition-colors"
style={{ color: paused() ? "rgba(255,255,255,0.5)" : "white" }}
>
0:0{secs()}
</span>
<span class="ml-auto font-mono text-[10px] font-medium text-white/50">
{paused() ? "Paused" : "Recording"}
</span>
</div>
<div class="flex gap-2">
<CaptureChip glyph={paused() ? "play" : "pause"} label={paused() ? "Resume" : "Pause"} />
<CaptureChip glyph="stop" label="Stop" />
</div>
<AudioToggles system mic={mic()} />
</div>
</Show>
</div>
</ModuleCard>
{/* cursor plotted on a fixed 360 px frame centered over the demo */}
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-[360px] -translate-x-1/2">
<FakeCursor phase={cursor().phase} at={cursor().at} />
</div>
</div>
);
}
/* ── Floating actions bloom around the open island ────────────────── */
/* Tiny monochrome glyphs for the action kinds the app offers. */
const GLYPH: Record<string, () => JSX.Element> = {
leaf: () => (
<path d="M3 13C3 7 7 3 13 3c0 6-4 10-10 10Zm0 0 6-6" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" />
),
bolt: () => <path d="M9 1 3 9h4l-1 6 6-8H8l1-6Z" fill="currentColor" />,
timer: () => (
<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<circle cx="8" cy="9" r="5.5" />
<path d="M8 9V6M6.5 1.5h3" />
</g>
),
speaker: () => (
<g fill="currentColor">
<path d="M2 6h3l4-3v10l-4-3H2Z" />
<path d="M11 5.5a3.5 3.5 0 0 1 0 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
</g>
),
shell: () => (
<path d="m3 5 3 3-3 3m5 0h5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round" />
),
stack: () => (
<g fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
<path d="m8 2 6 3-6 3-6-3 6-3Z" />
<path d="m2 8.5 6 3 6-3M2 11.5l6 3 6-3" />
</g>
),
link: () => (
<path d="M7 9a3 3 0 0 0 4 0l2-2a3 3 0 0 0-4-4l-1 1m1 3a3 3 0 0 0-4 0l-2 2a3 3 0 0 0 4 4l1-1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
)
};
interface FloatAction {
glyph: keyof typeof GLYPH;
label: string;
x: number;
y: number;
}
/* Positions mirror the app's slots: Left ·1/·2, Right ·1/·2, Bottom ·1–3. */
const ACTIONS: FloatAction[] = [
{ glyph: "leaf", label: "Low Power Mode on", x: -128, y: -18 },
{ glyph: "bolt", label: "Top-up to 100%", x: -128, y: 26 },
{ glyph: "speaker", label: "Output → Headphones", x: 128, y: -18 },
{ glyph: "stack", label: "Shortcut: Focus Time", x: 128, y: 26 },
{ glyph: "timer", label: "Timer · 25 min", x: -50, y: 78 },
{ glyph: "shell", label: "$ make deploy", x: 0, y: 78 },
{ glyph: "link", label: "Opened dashboard", x: 50, y: 78 }
];
export function FloatingActionsDemo() {
const [open, setOpen] = createSignal(false);
const [pressed, setPressed] = createSignal(-1);
const [lit, setLit] = createSignal<number[]>([]);
useDemoLoop(9600, [
[0, () => (setOpen(false), setPressed(-1), setLit([]))],
[400, () => setOpen(true)],
[2200, () => setPressed(0)],
[2500, () => setLit([0])],
[4000, () => setPressed(4)],
[4300, () => setLit([0, 4])],
[5800, () => setPressed(2)],
[6100, () => setLit([0, 4, 2])],
[8800, () => (setOpen(false), setPressed(-1))]
]);
const lastLit = () => lit()[lit().length - 1];
return (
<div class="relative mx-auto flex h-[240px] w-full max-w-[340px] items-start justify-center pt-6">
{/* the expanded island */}
<div
class="relative z-10 rounded-b-[18px] bg-black transition-all duration-500"
style={{
width: open() ? "190px" : "120px",
height: open() ? "96px" : "26px",
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)",
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
}}
>
<div
class="space-y-1.5 p-3 transition-opacity duration-300"
style={{ opacity: open() ? 1 : 0 }}
>
<div class="h-1.5 w-2/3 rounded bg-white/20" />
<div class="h-1.5 w-1/2 rounded bg-white/10" />
<div class="h-1.5 w-3/4 rounded" style={{ background: `${CALM}66` }} />
</div>
<For each={ACTIONS}>
{(a, i) => (
<span
class="absolute flex h-8 w-8 items-center justify-center rounded-full transition-all"
style={{
left: "50%",
top: "50%",
transform: open()
? `translate(calc(-50% + ${a.x}px), calc(-50% + ${a.y - 6}px)) scale(${pressed() === i() ? 0.82 : 1})`
: "translate(-50%, -50%) scale(0.2)",
opacity: open() ? 1 : 0,
background: lit().includes(i()) ? ACCENT : "#1c1c20",
color: lit().includes(i()) ? "white" : "rgba(255,255,255,0.7)",
"box-shadow": `inset 0 0 0 1px rgba(255,255,255,0.12), 0 4px 14px rgba(0,0,0,0.5)`,
"transition-duration": "520ms",
"transition-delay": open() ? `${i() * 45}ms` : `${(6 - i()) * 25}ms`,
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)"
}}
>
<svg width="15" height="15" viewBox="0 0 16 16">
{GLYPH[a.glyph]()}
</svg>
</span>
)}
</For>
</div>
{/* what the last press did */}
<div
class="absolute bottom-1 left-1/2 -translate-x-1/2 rounded-full px-3 py-1 text-[10px] font-semibold whitespace-nowrap text-white transition-all duration-300"
style={{
background: "rgba(72,151,178,0.22)",
opacity: lastLit() !== undefined && open() ? 1 : 0
}}
>
{lastLit() !== undefined ? ACTIONS[lastLit()].label : " "}
</div>
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { For, Show, createSignal } from "solid-js";
import Button from "~/components/ui/Button";
import Input from "~/components/ui/Input";
import { STATUS, ACCENT } from "./IslandMock";
import { useDemoLoop } from "./useDemoLoop";
import { betaRequestPayload } from "./betaRequest";
/**
* iPhone + Apple Watch companion, in TestFlight beta. The request form
* mirrors Settings ▸ Phone's BetaAccessCard in the app: it sends only the
* email, over the same `/api/the-nook/report` pipeline (see betaRequest.ts).
*/
type RequestState = "idle" | "sending" | "sent" | "limited" | "failed";
function requestBetaAccess(email: string): Promise<Response> {
return fetch("/api/the-nook/report", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(betaRequestPayload(email))
});
}
/* ── Demo: a request lands on the phone, a question on the wrist ─── */
function CompanionDemo() {
const [phase, setPhase] = createSignal<
"quiet" | "ask" | "allowed" | "question" | "answered"
>("quiet");
useDemoLoop(11000, [
[0, () => setPhase("quiet")],
[900, () => setPhase("ask")],
[3600, () => setPhase("allowed")],
[5400, () => setPhase("question")],
[8200, () => setPhase("answered")]
]);
const askVisible = () => phase() !== "quiet";
const allowed = () => phase() !== "quiet" && phase() !== "ask";
const working = () => (allowed() ? 3 : 2);
const waiting = () => (phase() === "ask" ? 1 : 0);
return (
<div class="flex items-end justify-center gap-5">
{/* iPhone */}
<div
class="relative h-[300px] w-[150px] overflow-hidden rounded-[28px] p-2"
style={{
background:
"radial-gradient(120% 80% at 30% 0%, #2b4a6b 0%, #191b2e 60%, #0d0d0f 100%)",
"box-shadow":
"inset 0 0 0 1.5px rgba(255,255,255,0.18), 0 20px 50px rgba(0,0,0,0.5)"
}}
>
<div class="mx-auto mt-1 h-[16px] w-[52px] rounded-full bg-black" />
<p class="mt-3 text-center text-[30px] leading-none font-semibold text-white/90">
9:41
</p>
{/* Lock Screen widget: what the numbers count */}
<div class="mt-2 flex justify-center gap-1">
<span
class="flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[8px] font-semibold whitespace-nowrap text-white/85"
style={{ background: "rgba(255,255,255,0.12)" }}
>
<span class="h-1.5 w-1.5 rounded-[1px]" style={{ background: STATUS.running }} />
{working()} working
</span>
<span
class="flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[8px] font-semibold whitespace-nowrap text-white/85"
style={{ background: "rgba(255,255,255,0.12)" }}
>
<span
class="h-1.5 w-1.5 rounded-[1px]"
style={{ background: waiting() ? STATUS.attention : STATUS.idle }}
/>
{waiting()} waiting
</span>
</div>
{/* notification */}
<div
class="absolute inset-x-2 top-[118px] rounded-2xl p-2 text-left transition-all duration-500"
style={{
background: "rgba(40,40,46,0.88)",
"backdrop-filter": "blur(8px)",
opacity: askVisible() ? 1 : 0,
transform: askVisible() ? "translateY(0)" : "translateY(-14px)"
}}
>
<div class="flex items-center gap-1">
<img src="/nook/icon.png" alt="" class="h-3 w-3 rounded-[3px]" />
<span class="text-[7.5px] font-semibold tracking-wide text-white/50 uppercase">
The Nook · now
</span>
</div>
<p class="mt-1 text-[9.5px] font-semibold text-white">
{allowed() ? "Allowed ✓" : "Claude Code · the-nook"}
</p>
<p class="text-[9px] leading-snug text-white/60">
{allowed() ? "Back to work." : "Run Bash: swift test"}
</p>
<Show when={!allowed()}>
<div class="mt-1.5 flex gap-1">
<span
class="flex-1 rounded-md py-0.5 text-center text-[8.5px] font-semibold text-white"
style={{ background: ACCENT }}
>
Allow
</span>
<span class="flex-1 rounded-md bg-white/10 py-0.5 text-center text-[8.5px] font-semibold text-white/80">
Deny
</span>
</div>
</Show>
</div>
</div>
{/* Apple Watch */}
<div class="flex flex-col items-center">
<div class="h-3 w-[64px] rounded-t-md bg-white/10" />
<div
class="relative h-[118px] w-[98px] overflow-hidden rounded-[26px] bg-black p-2.5"
style={{
"box-shadow":
"inset 0 0 0 2px rgba(255,255,255,0.16), 0 14px 34px rgba(0,0,0,0.5)",
transform: phase() === "question" ? "rotate(-2deg)" : "rotate(0deg)",
transition: "transform 0.15s"
}}
>
<Show
when={phase() === "question" || phase() === "answered"}
fallback={
<div class="flex h-full flex-col items-center justify-center">
<span class="font-mono text-[18px] font-semibold text-white/90">
{working()}
</span>
<span class="text-[8px] text-white/50">agents working</span>
</div>
}
>
<p class="text-[8px] font-semibold text-white/50">Codex asks</p>
<p class="mt-0.5 text-[9.5px] leading-snug font-semibold text-white">
Ship it to staging?
</p>
<div class="mt-2 space-y-1">
<For each={["Yes", "Not yet"]}>
{(option, i) => (
<span
class="block rounded-full py-1 text-center text-[8.5px] font-semibold transition-colors duration-300"
style={{
background:
phase() === "answered" && i() === 0
? STATUS.ready
: "rgba(255,255,255,0.12)",
color: "white"
}}
>
{phase() === "answered" && i() === 0 ? "Sent ✓" : option}
</span>
)}
</For>
</div>
</Show>
</div>
<div class="h-3 w-[64px] rounded-b-md bg-white/10" />
</div>
</div>
);
}
export default function PhoneBeta(props: { brandColor: string }) {
const [email, setEmail] = createSignal("");
const [state, setState] = createSignal<RequestState>("idle");
const submit = async () => {
const contact = email().trim();
if (!contact || state() === "sending") return;
setState("sending");
try {
const res = await requestBetaAccess(contact);
if (res.status === 429) return setState("limited");
if (!res.ok) return setState("failed");
setEmail("");
setState("sent");
} catch {
setState("failed");
}
};
return (
<section class="bg-base relative z-20 px-4 py-20 md:px-8" id="iphone">
<div class="mx-auto grid max-w-5xl grid-cols-1 items-center gap-12 md:grid-cols-2">
<div>
<span class="border-overlay0 bg-surface0 text-subtext0 mb-4 inline-block rounded-full border px-2.5 py-0.5 text-[11px] font-semibold tracking-wide">
TestFlight beta
</span>
<h2 class="text-text mb-3 text-3xl font-bold tracking-tight">
Your fleet, in your pocket
</h2>
<p class="text-subtext0 mb-5 leading-relaxed">
The iPhone and Apple Watch companion is in beta. Approvals,
questions, and replies reach you away from the desk, and your
answer goes straight back to the agent.
</p>
<ul class="mb-8 space-y-2.5">
<For
each={[
"Allow, deny, answer, and reply from iPhone or Apple Watch",
"Connects over an end-to-end encrypted relay, your tailnet, or your LAN",
"Home Screen, Lock Screen, and watch-face widgets for fleet status",
"Mute notifications for a while, or while you're active at the Mac"
]}
>
{(bullet) => (
<li class="text-subtext1 flex gap-2.5 text-sm">
<span
class="mt-[7px] inline-block h-1.5 w-1.5 shrink-0 rounded-[2px]"
style={{ background: ACCENT }}
/>
{bullet}
</li>
)}
</For>
</ul>
<div class="border-overlay0 bg-surface0 rounded-xl border p-6">
<h3 class="text-text mb-1 text-lg font-bold">Request beta access</h3>
<p class="text-subtext0 mb-4 text-sm">
Leave your email to get a TestFlight invite. We only receive your
email address.
</p>
<form
class="flex flex-col gap-3 sm:flex-row sm:items-end"
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<Input
type="email"
required
label="Email"
containerClass="flex-1"
disabled={state() === "sending"}
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
<Button
type="submit"
variant="primary"
color={props.brandColor}
loading={state() === "sending"}
>
Request access
</Button>
</form>
<Show when={state() === "sent"}>
<p class="text-subtext1 mt-4 text-sm">
Thanks. Watch your inbox for a TestFlight invite.
</p>
</Show>
<Show when={state() === "limited"}>
<p class="mt-4 text-sm text-red-500">
Too many requests from this network. Try again later.
</p>
</Show>
<Show when={state() === "failed"}>
<p class="mt-4 text-sm text-red-500">
Couldn't send the request. Check your connection and try again.
</p>
</Show>
</div>
</div>
<div
class="rounded-2xl px-6 py-10"
style={{
background:
"radial-gradient(70% 90% at 50% 0%, rgba(72,151,178,0.12) 0%, transparent 70%) #0d0d0f"
}}
>
<CompanionDemo />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,12 @@
/**
* The iPhone beta-access request the landing page posts to
* `/api/the-nook/report` — the same title the app's Settings ▸ Phone card
* sends, so requests from both land together. Email only, no device facts.
*/
export function betaRequestPayload(email: string) {
return {
title: "iPhone app beta access request",
description: "Beta access requested from nook.freno.me.",
contact: email
};
}

View File

@@ -0,0 +1,28 @@
import { onCleanup, onMount } from "solid-js";
/**
* Drives a looping landing-page demo: each `[atMs, step]` pair fires at its
* offset, and the whole schedule restarts every `periodMs`. Timers are torn
* down with the owning component.
*/
export function useDemoLoop(
periodMs: number,
steps: [atMs: number, step: () => void][]
): void {
onMount(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
const run = () => {
timers.length = 0;
for (const [at, step] of steps) {
if (at === 0) step();
else timers.push(setTimeout(step, at));
}
};
run();
const interval = setInterval(run, periodMs);
onCleanup(() => {
clearInterval(interval);
timers.forEach(clearTimeout);
});
});
}

View File

@@ -16,6 +16,9 @@
* prefix-strip below also handles any legacy prefixed form, so the canonical
* is always the public URL.
* - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`.
* Either way it is resolved to an **absolute** URL against the site's
* domain — OG consumers (X, Slack, Discord, iMessage) require an absolute
* URL, and a site-relative path in `ogDefaultImage` would 404 otherwise.
* - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall
* back to the base title (no suffix) / description (existing behavior).
*/
@@ -49,6 +52,20 @@ export interface ResolvedPageHeadMeta {
ogImage: string;
}
/**
* Resolve an OpenGraph image reference to an absolute URL.
*
* OG consumers (X, Slack, Discord, LinkedIn, iMessage) require an absolute
* URL; `ogDefaultImage` values are site-relative paths (`/nook/og.png`), so
* they are joined to the site origin here. Already-absolute references
* (`https:`, `data:`, protocol-relative `//`) pass through untouched.
*/
function absolutizeOgImage(image: string, domain: string): string {
if (image.startsWith("//")) return `https:${image}`;
if (/^[a-z][a-z0-9+.-]*:/i.test(image)) return image;
return `https://${domain}${image.startsWith("/") ? "" : "/"}${image}`;
}
export function resolvePageHeadMeta(
props: PageHeadProps,
site: Site,
@@ -71,7 +88,10 @@ export function resolvePageHeadMeta(
const canonical = props.canonical ?? `https://${site.domain}${publicPath}`;
const ogTitle = props.ogTitle ?? props.title;
const ogDescription = props.ogDescription ?? props.description;
const ogImage = props.ogImage ?? site.ogDefaultImage;
const ogImage = absolutizeOgImage(
props.ogImage ?? site.ogDefaultImage,
site.domain
);
return {
title,

View File

@@ -148,6 +148,9 @@ export interface VisitorAnalytics {
device_type?: string | null;
browser?: string | null;
os?: string | null;
utm_source?: string | null;
utm_medium?: string | null;
utm_campaign?: string | null;
duration_ms?: number | null;
fcp?: number | null;
lcp?: number | null;
@@ -157,6 +160,8 @@ export interface VisitorAnalytics {
ttfb?: number | null;
dom_load?: number | null;
load_complete?: number | null;
stage?: string | null;
host?: string | null;
created_at: string;
}

31
src/env/server.ts vendored
View File

@@ -53,18 +53,7 @@ const serverEnvSchema = z.object({
VITE_WEBSOCKET: z.string().min(1),
VITE_INFILL_ENDPOINT: z.string().min(1),
INFILL_BEARER_TOKEN: z.string().min(1),
NESSA_DB_URL: z.string().min(1),
NESSA_DB_TOKEN: z.string().min(1),
// Clerk authentication — Nessa auth is now Clerk-backed. The
// legacy self-issued JWT signing env var was removed.
NESSA_CLERK_SECRET: z.string().min(1),
NESSA_CLERK_JWT_ISSUER: z.string().min(1),
// Clerk webhook signing secret (Svix). Used to verify `user.created` /
// `user.updated` webhook payloads. Find it under Clerk Dashboard →
// Webhooks → your endpoint → Signing Secret (starts with `whsec_`).
NESSA_CLERK_WEBHOOK_SECRET: z.string().min(1),
LINEAGE_JWT_SECRET: z.string().min(32),
APPLE_CLIENT_ID_NESSA: z.string().min(1).optional(),
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
TURNSTILE_SECRET_KEY: z.string().min(1),
@@ -73,7 +62,20 @@ const serverEnvSchema = z.object({
NOOK_LICENSE_PRIVATE_KEY: z.string().min(1),
NOOK_STRIPE_SK: z.string().min(1),
NOOK_STRIPE_WEBHOOK_SECRET: z.string().min(1),
NOOK_STRIPE_PRICE_ID: z.string().min(1)
NOOK_STRIPE_PRICE_ID: z.string().min(1),
// Brevo list the trial-start emails land in (the lifecycle mail's
// audience). Optional: capture still stores the address when unset.
NOOK_TRIAL_LIST_ID: z.coerce.number().int().positive().optional(),
// Bearer secret for the daily lifecycle-email cron. Falls back to
// Vercel's CRON_SECRET; unset + no CRON_SECRET = the endpoint denies all.
NOOK_LIFECYCLE_SECRET: z.string().min(1).optional(),
// Daily VisitorAnalytics retention cron (/api/analytics-retention). Same
// shape: falls back to CRON_SECRET, and unset denies every request.
ANALYTICS_RETENTION_SECRET: z.string().min(1).optional(),
// How long a VisitorAnalytics row lives. Unset (or unusable) = 365 days;
// see src/server/analytics-retention.ts before shortening it.
ANALYTICS_RETENTION_DAYS: z.string().optional(),
CRON_SECRET: z.string().min(1).optional()
});
export type ServerEnv = z.infer<typeof serverEnvSchema>;
@@ -176,11 +178,6 @@ export const getMissingEnvVars = (): string[] => {
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
"VITE_GITHUB_CLIENT_ID",
"VITE_WEBSOCKET",
"NESSA_DB_URL",
"NESSA_DB_TOKEN",
"NESSA_CLERK_SECRET",
"NESSA_CLERK_JWT_ISSUER",
"NESSA_CLERK_WEBHOOK_SECRET",
"LINEAGE_JWT_SECRET"
];

View File

@@ -12,6 +12,35 @@
* Tests inject a fake `api` and a capture sink.
*/
/** Canonical analytics path for one download event. One label per asset, so a
* click on a landing page and a Sparkle update of the same app land in the
* same bucket on the analytics page. */
export const DOWNLOAD_PATH_PREFIX = "/api/downloads/";
export function downloadPath(asset: string): string {
return `${DOWNLOAD_PATH_PREFIX}${asset}`;
}
/**
* Group download rows by the asset named in their canonical path, and total
* them. Split out from the query so the labelling has a unit test without a
* database — the writer and the reader share `DOWNLOAD_PATH_PREFIX`, so the
* two cannot drift apart.
*/
export function summariseDownloads(
rows: Array<{ path: string; count: number }>
): { total: number; byAsset: Array<{ asset: string; count: number }> } {
const byAsset = rows.map((row) => ({
asset: row.path.slice(DOWNLOAD_PATH_PREFIX.length),
count: row.count
}));
return {
total: byAsset.reduce((sum, row) => sum + row.count, 0),
byAsset
};
}
/** Structural shape of the tRPC downloads proxy this helper depends on. */
export interface DownloadApi {
downloads: {

View File

@@ -120,6 +120,9 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
],
nook: [
{ label: "Home", href: "/", icon: "home" },
{ label: "Agents", href: "/agents", icon: "home" },
{ label: "Changelog", href: "/changelog", icon: "home" },
{ label: "FAQ", href: "/faq", icon: "home" },
{ label: "Privacy", href: "/privacy", icon: "privacy" }
]
};

View File

@@ -132,7 +132,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
titleSuffix: " | The Nook",
brandColor: "#4C9FBC",
brandColorDark: "#4C9FBC",
ogDefaultImage: "/nook/og-default.png",
ogDefaultImage: "/nook/og.png",
faviconPath: "/nook/favicon/favicon.ico"
}
};

View File

@@ -83,6 +83,9 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
nook: [
{ path: "/", changefreq: "weekly", priority: 1.0 },
{ path: "/checkout", changefreq: "monthly", priority: 0.5 },
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
{ path: "/privacy", changefreq: "yearly", priority: 0.4 },
{ path: "/agents", changefreq: "weekly", priority: 0.9 },
{ path: "/changelog", changefreq: "daily", priority: 0.7 },
{ path: "/faq", changefreq: "monthly", priority: 0.8 }
]
};

View File

@@ -1,5 +1,5 @@
import { PageHead } from "~/components/PageHead";
import { HttpStatusCode } from "@solidjs/start";
import { HttpHeader, HttpStatusCode } from "@solidjs/start";
import { useLocation, useNavigate } from "@solidjs/router";
import { createSignal, onCleanup, onMount, Show } from "solid-js";
import { TerminalErrorPage } from "~/components/TerminalErrorPage";
@@ -90,6 +90,14 @@ export default function NotFound() {
description="404 - Page not found. The page you're looking for doesn't exist."
/>
<HttpStatusCode code={404} />
{/* Cache 404/fallback responses at the edge (Vercel caches 404s) so
bots/monitors that probe dead paths don't re-render the full page
on every request. Function headers override vercel.json here. */}
<HttpHeader name="Cache-Control" value="public, max-age=0" />
<HttpHeader
name="CDN-Cache-Control"
value="public, s-maxage=300, stale-while-revalidate=86400"
/>
<TerminalErrorPage
errorContent={errorContent}
quickActions={quickActions}

24
src/routes/agents.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NookAgentsPage from "./nook/agents";
/**
* Host-aware agents route — `/agents`.
*
* The supported-agents page is a Nook-subdomain surface; all other sites
* fall back to 404. Mirrors the dispatch pattern in `src/routes/index.tsx`
* and `src/routes/checkout.tsx` — and like them, this is what makes the
* page render at all: production does not apply the vercel.json host
* rewrites (SSR sees `/agents`, never `/nook/agents`).
*/
export default function AgentsPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nook"}>
<NookAgentsPage />
</Match>
</Switch>
);
}

View File

@@ -202,6 +202,32 @@ function FrenomePanel(props: FrenomePanelProps) {
/>
</div>
{/* Downloads — every door logs one row at /api/downloads/<asset> */}
<Show when={data.downloads.total > 0}>
<div class="bg-surface0 border-surface1 mb-8 rounded-lg border shadow">
<div class="border-surface1 border-b p-6">
<h2 class="text-text text-2xl font-bold">Downloads</h2>
<p class="text-subtext0 mt-1 text-sm">
{formatNumber(data.downloads.total)} events in the last
{props.days === 1 ? " 24 hours" : ` ${props.days} days`} —
landing page clicks and files served, by product
</p>
</div>
<div class="space-y-3 p-6">
<For each={data.downloads.byAsset}>
{(download) => (
<StatBar
label={download.asset}
count={download.count}
total={data.downloads.total}
color="bg-teal-600"
/>
)}
</For>
</div>
</div>
</Show>
{/* Performance */}
<Show when={performanceStats()} keyed>
{(ps) => (

View File

@@ -0,0 +1,35 @@
import type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server";
import { ConnectionFactory } from "~/server/db-connections";
import { runRetentionCron } from "~/server/analytics-retention";
/**
* GET /api/analytics-retention — daily Vercel cron.
*
* Deletes `VisitorAnalytics` rows older than the retention window, so visitor
* data has a bound instead of accumulating forever (FRE-369, finding Q4 — the
* table's only cleaner used to be a manual admin call, and since FRE-366 the
* rows carry a truncated but still real client address).
*
* Window: `ANALYTICS_RETENTION_DAYS`, defaulting to 365 when unset or unusable
* (see `~/server/analytics-retention` for why the default is conservative
* rather than the reviewer's 90).
*
* Auth: `Authorization: Bearer <secret>` where the secret is
* `ANALYTICS_RETENTION_SECRET` or Vercel's `CRON_SECRET`. An unconfigured secret
* denies every request — an unauthenticated trigger of a bulk delete is the one
* outcome this endpoint must never have.
*/
export async function GET(event: APIEvent) {
const result = await runRetentionCron({
authorization: event.request.headers.get("authorization"),
secret: env.ANALYTICS_RETENTION_SECRET ?? env.CRON_SECRET,
configuredDays: env.ANALYTICS_RETENTION_DAYS,
conn: ConnectionFactory()
});
return new Response(JSON.stringify(result.body), {
status: result.status,
headers: { "Content-Type": "application/json" }
});
}

View File

@@ -1,52 +0,0 @@
import type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server";
import { NessaConnectionFactory } from "~/server/database";
import { handleClerkUserWebhook } from "~/server/clerk-user-webhook";
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" }
});
}
/**
* Clerk webhook endpoint — receives `user.created` / `user.updated` events.
*
* Configure the endpoint URL in the Clerk Dashboard → Webhooks
* (e.g. https://freno.me/api/clerk-webhook in prod, or an ngrok/dev url for
* local dev). The signing secret (`whsec_...`) is stored in
* `NESSA_CLERK_WEBHOOK_SECRET` and used to verify each request via Svix.
*
* The raw body is read verbatim from the inflight request so the Svix
* signature is computed over the exact bytes Clerk sent.
*/
export async function POST(event: APIEvent) {
const svixId = event.request.headers.get("svix-id");
const svixTimestamp = event.request.headers.get("svix-timestamp");
const svixSignature = event.request.headers.get("svix-signature");
if (!svixId || !svixTimestamp || !svixSignature) {
return json(400, { error: "Missing Svix signature headers" });
}
let rawBody: string;
try {
rawBody = await event.request.text();
} catch {
return json(400, { error: "Missing request body" });
}
const result = await handleClerkUserWebhook({
rawBody,
headers: {
"svix-id": svixId,
"svix-timestamp": svixTimestamp,
"svix-signature": svixSignature
},
webhookSecret: env.NESSA_CLERK_WEBHOOK_SECRET,
conn: NessaConnectionFactory()
});
return json(result.status, result.body);
}

View File

@@ -1,6 +1,7 @@
import type { APIEvent } from "@solidjs/start/server";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { env } from "~/env/server";
import { trackDownload } from "~/server/track-download";
/**
* Serves macOS app DMG files and delta updates from S3
@@ -8,7 +9,10 @@ import { env } from "~/env/server";
*
* Handles:
* - Full DMG files: /api/downloads/Gaze-0.2.2.dmg, /api/downloads/InputHalo-0.1.0.dmg
* - Delta updates: /api/downloads/Gaze3-2.delta, /api/downloads/InputHalo3-2.delta
* - Delta updates: /api/downloads/Gaze3-2.delta, /api/downloads/Nook17-9.delta
*
* The Nook delta files are named after the app bundle ("Nook"), not the DMG
* ("TheNook"), so both prefixes must be allowed (NOOK-337).
*
* URL: https://freno.me/api/downloads/[filename]
*/
@@ -24,10 +28,20 @@ export async function GET(event: APIEvent) {
});
}
const validPrefixes = ["Gaze", "InputHalo", "TheNook"];
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
// Prefix → canonical asset key, so a version bump stays one asset in
// analytics rather than one per release.
const assetKeys: Record<string, string> = {
Gaze: "gaze",
InputHalo: "inputhalo",
TheNook: "thenook",
Nook: "thenook"
};
const asset = Object.keys(assetKeys).find((prefix) =>
filename.startsWith(prefix)
);
if (
!isValidPrefix ||
!asset ||
(!filename.endsWith(".dmg") &&
!filename.endsWith(".delta") &&
!filename.endsWith(".zip"))
@@ -81,13 +95,20 @@ export async function GET(event: APIEvent) {
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
return new Response(body, {
await trackDownload(event, assetKeys[asset]!, "served");
// SAFETY: `transformToByteArray` yields `Uint8Array<ArrayBufferLike>`, which the
// DOM lib's `BufferSource` (ArrayBuffer-backed) does not accept, though undici
// takes it as-is at runtime. State the intent at the one boundary it hits.
return new Response(body as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Length": body.length.toString(),
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
// Deliberately open: these are public installer files with no cookies or
// credentials, fetched by Sparkle and by direct browser navigation.
"Access-Control-Allow-Origin": "*"
}
});

View File

@@ -0,0 +1,48 @@
/**
* Regression test for the downloads proxy filename allowlist (NOOK-337).
*
* The v0.10.0 publish failed its CI enclosure check because Sparkle names
* delta files after the app bundle ("Nook17-9.delta") while the DMG is named
* "TheNook-*.dmg" — and "Nook" was missing from the allowlist, so every Nook
* delta request 400'd with "Invalid file format" while the DMG passed.
*
* The route hits S3 and cannot be unit-tested under bun:test, so this is a
* STATIC SOURCE AUDIT — the same pattern the downloads.test.ts regressions
* use.
*/
import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const SOURCE = readFileSync(
join(import.meta.dir, "[filename].ts"),
"utf8"
);
/**
* The allowlist moved from an array to a `prefix → asset key` map when download
* tracking was added, so read the keys out of the map rather than matching
* quoted strings.
*/
const ALLOWLIST_KEYS = [
...(/const assetKeys: Record<string, string> = \{([^}]*)\}/.exec(SOURCE)?.[1] ?? "")
.matchAll(/"?([A-Za-z]+)"?\s*:/g)
].map((match) => match[1]);
describe("Downloads proxy filename allowlist (NOOK-337)", () => {
it("allows the Nook app-bundle prefix used by Sparkle delta names", () => {
expect(ALLOWLIST_KEYS).toContain("Nook");
});
it("still allows every previously served prefix", () => {
for (const prefix of ["Gaze", "InputHalo", "TheNook"]) {
expect(ALLOWLIST_KEYS).toContain(prefix);
}
});
it("still serves the extensions Sparkle fetches", () => {
for (const ext of [".dmg", ".delta", ".zip"]) {
expect(SOURCE).toContain(`"${ext}"`);
}
});
});

23
src/routes/api/health.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { APIEvent } from "@solidjs/start/server";
/**
* Lightweight uptime/monitoring probe.
*
* Returns a tiny 200 that the edge CDN caches (`CDN-Cache-Control`), so
* health checks (Sentry uptime monitors, external monitors, `curl`) cost a
* fraction of a rendered page instead of a full SSR response. Point monitors
* at `https://freno.me/api/health`.
*/
export async function GET(_event: APIEvent) {
return new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
// Browser: always revalidate (tiny body, no reason to cache).
"Cache-Control": "public, max-age=0",
// Edge CDN: serve from cache for 1 min, then stale-while-revalidate
// for a day so repeated probe hits never touch the origin.
"CDN-Cache-Control": "public, s-maxage=60, stale-while-revalidate=86400"
}
});
}

View File

@@ -0,0 +1,8 @@
import type { APIEvent } from "@solidjs/start/server";
import { rest } from "../_lib";
// POST /api/lineage/pvp/purchase — server-authoritative Blood Token purchase
export const POST = (event: APIEvent) => rest(async (caller) => {
const input = await event.request.json();
return caller.lineage.pvp.purchase(input);
}, event);

View 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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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) : [];
}

View File

@@ -0,0 +1,75 @@
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { env } from "~/env/server";
/**
* Download resolution for The Nook, kept out of the route module on purpose.
*
* Vinxi's route tree-shake (`vinxi/lib/plugins/tree-shake.babel.js`) deletes
* every exported declaration in a route file whose name is not in the
* `?pick=` list the router asks for — and a route is picked as `pick=GET`.
* A helper exported from `download.ts` is therefore removed while `GET`
* still references it, which fails at runtime with "is not defined" in dev
* and in the production bundle alike. Only HTTP handlers may be exported
* from a route; helpers live here, where the transform never runs. Same
* reason `_lib.ts` exists.
*/
const APPCast_KEY = "api/TheNook/appcast.xml";
/** Kept in step with the appcast route's own 5-minute cache. */
const RESOLVE_TTL_MS = 5 * 60 * 1000;
const ENCLOSURE_RE = /<enclosure\b[^>]*\burl="([^"]+)"/gi;
/**
* Picks the newest release artifact out of a Sparkle appcast: the first
* enclosure that is a .dmg, else the first .zip, else the first enclosure
* of any kind. Enclosures appear in newest-first order.
*/
export function pickEnclosure(xml: string): string | null {
const urls: string[] = [];
for (const match of xml.matchAll(ENCLOSURE_RE)) {
if (match[1]) urls.push(match[1]);
}
return (
urls.find((u) => /\.dmg(\?|$)/i.test(u)) ??
urls.find((u) => /\.zip(\?|$)/i.test(u)) ??
urls[0] ??
null
);
}
let cached: { url: string; at: number } | null = null;
/** Drops the cached enclosure so the next request re-reads the appcast. */
export function invalidateDownloadCache(): void {
cached = null;
}
/** Resolves (and briefly caches) the current download URL from the appcast. */
export async function resolveLatestDownload(): Promise<string | null> {
if (cached && Date.now() - cached.at < RESOLVE_TTL_MS) {
return cached.url;
}
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();
const url = pickEnclosure(xml);
if (url) cached = { url, at: Date.now() };
return url;
}

View File

@@ -0,0 +1,185 @@
/**
* The release list for The Nook's in-app changelog (Settings > Changelog).
*
* The /changelog page already answers this question, but only as HTML, and it
* assembles the list inside the route component. A native client must not
* couple to that markup, and the appcast alone is not the full story: releases
* older than the appcast's retention live only in the checked-in archive. So
* this module owns that merge — live appcast items, their notes filled from the
* archive wherever the feed lost them, then archived releases the appcast has
* aged out — and normalizes it into data a client can render without parsing
* HTML. `withArchivedNotes` is exported so /changelog runs the identical rule
* rather than a second copy of it.
*
* Underscore-prefixed file — not a route.
*/
import { ARCHIVED_RELEASES } from "~/routes/nook/changelog-archive";
import { fetchAppcastReleases, type AppcastRelease } from "./_appcast";
export interface ReleaseSection {
/** "Added" / "Changed" / "Fixed"; "Notes" when the notes carry no label. */
title: string;
bullets: string[];
}
export interface AppRelease {
version: string;
/** Build number, null for archived releases (they carry none). */
build: string | null;
/** ISO calendar date, "2026-10-02"; null when no date was published. */
date: string | null;
sections: ReleaseSection[];
}
const ENTITY_REPLACEMENTS: [RegExp, string][] = [
[/&lt;/g, "<"],
[/&gt;/g, ">"],
[/&quot;/g, '"'],
// Python's html.escape, which embed_release_notes.py writes the appcast
// with, prefers the hex form for apostrophes (NOOK-565).
[/&#x27;/g, "'"],
[/&#39;/g, "'"],
[/&apos;/g, "'"],
[/&nbsp;/g, " "],
// Last, so "&amp;lt;" cannot decode twice.
[/&amp;/g, "&"]
];
function decodeEntities(text: string): string {
return ENTITY_REPLACEMENTS.reduce((acc, [re, to]) => acc.replace(re, to), text);
}
function stripTags(text: string): string {
return text.replace(/<[^>]*>/g, "");
}
/** Collapses the whitespace an HTML source wraps bullets with. */
function oneLine(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
/**
* Reads the notes shape `embed_release_notes.py` writes —
* `<p><b>Added</b></p><ul><li>…</li></ul>` — into titled bullet groups.
*
* A `<ul>` with no label before it (what `generate_appcast` writes on its own)
* becomes "Notes". A `<p>` is taken as the next list's label rather than as
* prose, which is safe for notes this pipeline generates and degrades to a
* stray heading if that ever changes.
*/
export function sectionsFromHtml(html: string): ReleaseSection[] {
const sections: ReleaseSection[] = [];
let label = "";
for (const block of html.matchAll(/<p>([\s\S]*?)<\/p>|<ul[^>]*>([\s\S]*?)<\/ul>/gi)) {
if (block[1] !== undefined) {
label = oneLine(stripTags(decodeEntities(block[1])));
continue;
}
const bullets = [...block[2]!.matchAll(/<li[^>]*>([\s\S]*?)<\/li>/gi)]
.map((b) => oneLine(stripTags(decodeEntities(b[1] ?? ""))))
.filter((b) => b.length > 0);
if (bullets.length === 0) continue;
sections.push({ title: label || "Notes", bullets });
label = "";
}
return sections;
}
/**
* Normalizes a published date to an ISO calendar date. The appcast carries an
* RFC-1123 `pubDate`, the archive a plain `2026-09-06`.
*
* The day is taken at the timestamp's own offset, not in UTC: a release
* published 23:29 -0400 is dated the 2nd, and reading it as UTC would move it
* to the 3rd.
*/
export function isoDateOrNull(raw: string | null): string | null {
const value = raw?.trim();
if (!value) return null;
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
const offset = /([+-])(\d{2}):?(\d{2})$/.exec(value);
const offsetMinutes = offset
? (offset[1] === "-" ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3]))
: 0;
return new Date(parsed.getTime() + offsetMinutes * 60_000).toISOString().slice(0, 10);
}
function toAppRelease(release: AppcastRelease | ArchivedReleaseShape): AppRelease {
return {
version: release.version,
build: "build" in release ? release.build : null,
date: isoDateOrNull(release.date),
sections: sectionsFromHtml(release.notesHtml)
};
}
/** The archive carries no build number; this is that shape without it. */
type ArchivedReleaseShape = {
version: string;
date: string;
notesHtml: string;
};
/**
* Notes the appcast lost, filled from the archive.
*
* An item with no notes renders as a version header over a blank row, in the
* app and on /changelog alike. That is not hypothetical: Sparkle rebuilds every
* item from its DMG and removes the description of one whose DMG carries no
* notes file, so 0.8.0 and 0.7.0 published with notes and reached both
* surfaces with none (NOOK-304).
*
* The archive copy is used only where the live one is missing. Where both
* exist the published item wins, because that is the text the updater itself
* showed, and it carries the build number the archive does not have.
*/
export function withArchivedNotes<
T extends { version: string; notesHtml: string }
>(
live: readonly T[],
archived: readonly { version: string; notesHtml: string }[]
): T[] {
const notesByVersion = new Map(archived.map((r) => [r.version, r.notesHtml]));
return live.map((release) => {
if (release.notesHtml.trim()) return release;
const fallback = notesByVersion.get(release.version);
return fallback ? { ...release, notesHtml: fallback } : release;
});
}
/**
* Live releases first, newest first, then any archived release the appcast no
* longer lists. Order is concatenation rather than a sort because both inputs
* are newest-first and the archive only ever holds older releases — and because
* /changelog renders this same list, the page and this feed cannot disagree
* about what a release said.
*/
export function mergeReleases(
live: readonly AppcastRelease[],
archived: readonly ArchivedReleaseShape[]
): AppRelease[] {
const releases = withArchivedNotes(live, archived).map(toAppRelease);
const seen = new Set(releases.map((r) => r.version));
for (const release of archived) {
if (seen.has(release.version)) continue;
releases.push(toAppRelease(release));
}
return releases;
}
/**
* Everything the in-app changelog shows. Throws when the appcast cannot be
* read — the caller must not serve an archived-only list as if it were
* current, since the app then still has the newest release in its own bundle.
*/
export async function collectReleases(): Promise<AppRelease[]> {
return mergeReleases(await fetchAppcastReleases(), ARCHIVED_RELEASES);
}

View 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>&lt;p&gt;Hello&lt;/p&gt;</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");
});
});

View File

@@ -3,6 +3,7 @@ import { env } from "~/env/server";
import { TURNSTILE_CONFIG } from "~/config";
import { verifyTurnstileToken } from "~/server/fetch-utils";
import { json, error } from "./_lib";
import { trackRequest } from "~/server/track-download";
/**
* POST /api/the-nook/checkout
@@ -12,6 +13,14 @@ import { json, error } from "./_lib";
* session for the one-time $10 The Nook license. Returns the hosted
* checkout URL for the client to redirect to.
*
* Analytics: a created session is the funnel's "checkout started" stage
* (FRE-366). Until then the only trace was the client-side /checkout page
* view — a page, not a started checkout. Recorded through the shared
* `trackRequest` writer (referrer, campaign tags, country, real client
* address, host) under this route's own path, best-effort: a failed
* analytics write never blocks a checkout, and an attempt Turnstile or
* Stripe rejects is correctly not a started checkout.
*
* Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no
* `stripe` npm dependency added.
*/
@@ -67,5 +76,14 @@ export async function POST(event: APIEvent) {
if (!data.url) {
return error("Stripe returned no checkout URL", 502);
}
// The session exists: that is "checkout started". Awaited so the row is
// flushed before the response — a buffered write is not guaranteed to
// survive the response on serverless.
await trackRequest(event, {
path: "/api/the-nook/checkout",
method: "POST"
});
return json({ checkoutUrl: data.url });
}

View File

@@ -0,0 +1,218 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
import { truncateIpAddress } from "~/server/request-client";
// The route's contracts:
// 1. Every click is recorded through the shared analytics buffer with the
// path, referrer, country and device type the funnel needs.
// 2. The response is a no-store 302 to the newest release enclosure, so a
// CDN can never serve a later click without it reaching this handler.
// 3. An unreadable appcast degrades to 503 — but the click is still logged,
// because a failed download is exactly the event worth seeing.
type Entry = Record<string, unknown>;
const logged: Entry[] = [];
let appcastXml = "";
let s3ShouldThrow = false;
let flushCalls = 0;
mock.module("~/env/server", () => ({
env: {
AWS_REGION: "us-east-1",
MY_AWS_ACCESS_KEY: "test-key",
MY_AWS_SECRET_KEY: "test-secret",
VITE_DOWNLOAD_BUCKET_STRING: "test-bucket"
}
}));
mock.module("@aws-sdk/client-s3", () => ({
S3Client: class {
async send() {
if (s3ShouldThrow) throw new Error("s3 unavailable");
return {
Body: appcastXml
? { transformToString: async () => appcastXml }
: null
};
}
},
GetObjectCommand: class {
input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
}));
mock.module("vinxi/http", () => ({
getRequestIP: () => null
}));
mock.module("~/server/analytics", () => ({
logVisit: async (entry: Entry) => {
logged.push(entry);
},
// The route must flush, not merely buffer: the batch timer is not
// guaranteed to survive the response on serverless.
flushAnalytics: async () => {
flushCalls += 1;
},
// Mirrors the real enrichment contract: deviceType is derived from the UA,
// and the address is cut to its prefix by the FRE-369 privacy floor. The
// real `enrichAnalyticsEntry` does both, so the mirror has to apply the same
// truncation — otherwise this file would prove the door stores a full address.
enrichAnalyticsEntry: (entry: Entry) => ({
...entry,
ipAddress: truncateIpAddress(entry.ipAddress as string | null),
deviceType: String(entry.userAgent ?? "").includes("Macintosh")
? "desktop"
: null
})
}));
const { GET } = await import("./download");
const { pickEnclosure, invalidateDownloadCache } = await import("./_download");
const DMG = "https://cdn.example/TheNook-0.6.3.dmg";
const ZIP = "https://cdn.example/TheNook-0.6.3.zip";
const appcast = (urls: string[]) =>
`<?xml version="1.0"?><rss><channel><item>` +
urls
.map(
(u) =>
`<enclosure url="${u}" sparkle:version="1" length="1" type="application/octet-stream"/>`
)
.join("") +
`</item></channel></rss>`;
function request(headers: Record<string, string> = {}, query = "") {
return {
request: new Request(
`https://nook.freno.me/api/the-nook/download${query}`,
{ headers }
),
nativeEvent: {}
} as never;
}
const MAC_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15";
beforeEach(() => {
logged.length = 0;
appcastXml = appcast([DMG, ZIP]);
s3ShouldThrow = false;
flushCalls = 0;
invalidateDownloadCache();
});
describe("GET /api/the-nook/download", () => {
it("logs the click and 302s to the newest dmg", async () => {
const res = await GET(request({ referer: "https://nook.freno.me/" }));
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe(DMG);
expect(res.headers.get("Cache-Control")).toBe("no-store");
expect(logged.length).toBe(1);
expect(logged[0]).toMatchObject({
path: "/api/downloads/thenook",
method: "GET",
// The door is the click half of the split; the file route writes
// "served". And the row names the site it came from (FRE-366).
stage: "click",
host: "nook.freno.me",
referrer: "https://nook.freno.me/"
});
// Flushed within the request, not left to the batch timer.
expect(flushCalls).toBe(1);
});
it("derives the device type from the user agent", async () => {
await GET(request({ "user-agent": MAC_UA }));
expect(logged[0]!.deviceType).toBe("desktop");
});
it("records the edge country, and lets ?c= override it", async () => {
await GET(request({ "x-vercel-ip-country": "GB" }));
expect(logged[0]!.country).toBe("GB");
invalidateDownloadCache();
await GET(request({ "cf-ipcountry": "DE" }));
expect(logged[1]!.country).toBe("DE");
invalidateDownloadCache();
await GET(request({ "x-vercel-ip-country": "GB" }, "?c=JP"));
expect(logged[2]!.country).toBe("JP");
});
it("records the forwarded visitor below the trusted edge, truncated", async () => {
// Client → Cloudflare → origin proxy: the sender's spoof is first, the real
// client second, the edge last. And the stored value is the /24, never the
// address itself (FRE-369).
await GET(
request({
"x-forwarded-for": "198.51.100.9, 203.0.113.7, 198.51.100.1"
})
);
expect(logged[0]!.ipAddress).toBe("203.0.113.0");
});
it("still logs the click when the appcast cannot be read", async () => {
s3ShouldThrow = true;
const res = await GET(request());
expect(res.status).toBe(503);
expect(res.headers.get("Cache-Control")).toBe("no-store");
expect(logged.length).toBe(1);
});
it("503s when the appcast carries no enclosure", async () => {
appcastXml = "<?xml version='1.0'?><rss><channel></channel></rss>";
invalidateDownloadCache();
const res = await GET(request());
expect(res.status).toBe(503);
});
it("captures campaign tags from the click URL and the referer", async () => {
await GET(
request(
{ referer: "https://nook.freno.me/?utm_source=hn&utm_campaign=launch" },
"?utm_medium=post"
)
);
expect(logged[0]).toMatchObject({
utmSource: "hn",
utmMedium: "post",
utmCampaign: "launch"
});
});
it("logs nulls when nothing is tagged", async () => {
await GET(request({ referer: "https://nook.freno.me/" }));
expect(logged[0]).toMatchObject({
utmSource: null,
utmMedium: null,
utmCampaign: null
});
});
});
describe("pickEnclosure", () => {
it("prefers a dmg over a zip even when the zip comes first", () => {
expect(pickEnclosure(appcast([ZIP, DMG]))).toBe(DMG);
});
it("falls back to a zip, then to any enclosure", () => {
expect(pickEnclosure(appcast([ZIP]))).toBe(ZIP);
expect(pickEnclosure(appcast(["https://cdn.example/notes.txt"]))).toBe(
"https://cdn.example/notes.txt"
);
});
it("returns null when there is no enclosure", () => {
expect(pickEnclosure("<rss><channel></channel></rss>")).toBeNull();
});
});

View File

@@ -0,0 +1,55 @@
import type { APIEvent } from "@solidjs/start/server";
import { trackDownload } from "~/server/track-download";
import { resolveLatestDownload } from "./_download";
/**
* GET /api/the-nook/download
*
* The measured download door. Both hero buttons point here instead of
* resolving the appcast in the browser: this route resolves the latest
* release enclosure server-side, records the click through the shared
* analytics buffer, then 302s to the DMG.
*
* Why a redirect and not a direct S3 link: the appcast route and the DMG
* route never logged anything, so step 2 of the funnel (download click →
* DMG served) was invisible. Every click now lands one row in
* VisitorAnalytics under `/api/downloads/thenook` with `stage = "click"`,
* distinct from the `stage = "served"` row the file route writes when the
* DMG is actually streamed — the split FRE-366 added, alongside the clicks
* and served files of every other product — see `~/server/track-download`.
*
* Referrer, campaign tags, edge country (with `?c=` override) and user agent
* are read by `trackDownload`, which is shared with the other download doors.
*
* ONLY the handler is exported here: Vinxi's route tree-shake deletes
* exported declarations that are not in the router's `pick=` list, so a
* helper exported from this file would be removed while `GET` still
* referenced it. All resolution helpers live in `./_download.ts`.
*/
export async function GET(event: APIEvent): Promise<Response> {
// Recorded before resolving: a click that dies at resolution is exactly the
// failure the funnel needs to show.
await trackDownload(event, "thenook", "click");
let target: string | null = null;
try {
target = await resolveLatestDownload();
} catch (error) {
console.error("Failed to resolve Nook download:", error);
}
if (!target) {
return new Response("Download unavailable", {
status: 503,
headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" }
});
}
// No-store is load-bearing: a cached 302 would serve later clicks without
// ever reaching this handler, silently dropping them from the funnel.
return new Response(null, {
status: 302,
headers: { Location: target, "Cache-Control": "no-store" }
});
}

View File

@@ -0,0 +1,230 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
// The lifecycle job's contracts:
// 1. Auth is Bearer-only; an unconfigured secret denies everything (fail
// closed) — an unauthenticated trigger would re-email real users.
// 2. Trials younger than 11 days get nothing; at ≥ 11 days the reminder is
// sent and the row is marked, so a re-run never double-sends.
// 3. A trial address that already bought a license is skipped (marked, no
// buy reminder).
// 4. Licenses get the testimonial ask at ≥ 3 days and the review ask at
// ≥ 14 days, each once.
// DB + email sends are mocked; the route module is imported after mocks.
const UUID = "a1b2c3d4-e5f6-4a1b-8c2d-1234567890ab";
const NOW = Date.now();
const daysAgo = (n: number) => new Date(NOW - n * 24 * 60 * 60 * 1000).toISOString();
let trialRows: Record<string, unknown>[] = [];
let licenseRows: Record<string, unknown>[] = [];
const updates: { sql: string; args?: unknown[] }[] = [];
const sent: string[] = [];
// When true, conditional-claim UPDATEs report rowsAffected 0 (another
// invocation won the claim first).
let racingClaim = false;
const fakeConn = {
execute: async (q: { sql: string; args?: unknown[] } | string) => {
const { sql: rawSql, args } =
typeof q === "string" ? { sql: q, args: undefined } : q;
const sql = rawSql.trim();
if (sql.startsWith("SELECT fingerprint")) {
return { rows: trialRows, rowsAffected: 0 };
}
if (sql.startsWith("SELECT id, email")) {
return { rows: licenseRows, rowsAffected: 0 };
}
if (sql.startsWith("SELECT 1 FROM licenses")) {
const email = args?.[0];
const owned = licenseRows.some((r) => r.email === email);
return { rows: owned ? [{ 1: 1 }] : [], rowsAffected: 0 };
}
if (sql.startsWith("UPDATE")) {
updates.push({ sql, args });
const claimed = racingClaim ? 0 : 1;
return { rows: [], rowsAffected: claimed };
}
return { rows: [], rowsAffected: 0 };
}
};
mock.module("~/server/db-connections", () => ({
NookConnectionFactory: () => fakeConn
}));
mock.module("~/server/nook", () => ({
// Full export surface: bun's mock registry is shared across test files in
// one run, and a partial mock here would starve resend-license.test.ts.
nookSchemaBootstrap: Promise.resolve(),
emailLicenseKey: async () => {},
issueLicense: async () => ({ key: "", id: "" }),
grantLicense: async () => ({ key: "", id: "" }),
setTrialEmail: async (fingerprint: string, email: string) => {
updates.push({ sql: "setTrialEmail", args: [fingerprint, email] });
return true;
},
verifyLicenseKey: () => true
}));
mock.module("~/server/nook-lifecycle", () => ({
emailTrialReminder: async (to: string) => {
sent.push(`reminder:${to}`);
return true;
},
emailTestimonialAsk: async (to: string) => {
sent.push(`testimonial:${to}`);
return true;
},
emailReviewAsk: async (to: string) => {
sent.push(`review:${to}`);
return true;
}
}));
mock.module("~/env/server", () => ({
env: {
NOOK_LIFECYCLE_SECRET: "sekrit",
SENDINBLUE_KEY: "test-sendinblue",
NOOK_LICENSE_PRIVATE_KEY: "test",
NOOK_STRIPE_WEBHOOK_SECRET: "test",
AWS_REGION: "us-east-1",
MY_AWS_ACCESS_KEY: "test-key",
MY_AWS_SECRET_KEY: "test-secret",
VITE_DOWNLOAD_BUCKET_STRING: "test-bucket"
}
}));
const { GET } = await import("./lifecycle");
function request(auth?: string): any {
return {
request: { headers: new Headers(auth ? { authorization: auth } : {}) }
};
}
describe("GET /api/the-nook/lifecycle", () => {
beforeEach(() => {
trialRows = [];
licenseRows = [];
updates.length = 0;
sent.length = 0;
});
it("denies missing, wrong, and unconfigured secrets", async () => {
const noHeader = await GET(request());
expect(noHeader.status).toBe(401);
const wrong = await GET(request("Bearer nope"));
expect(wrong.status).toBe(401);
expect(sent).toHaveLength(0);
});
it("sends the D+11 reminder to an aged trial and marks it", async () => {
trialRows = [
{ fingerprint: UUID, email: "trial@example.com", started_at: daysAgo(11) }
];
const res = await GET(request("Bearer sekrit"));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.reminders).toBe(1);
expect(sent).toEqual(["reminder:trial@example.com"]);
expect(
updates.some((u) => u.sql.includes("reminder_sent_at"))
).toBe(true);
});
it("skips young trials entirely", async () => {
trialRows = [
{ fingerprint: UUID, email: "young@example.com", started_at: daysAgo(3) }
];
const res = await GET(request("Bearer sekrit"));
const body = await res.json();
expect(body.reminders).toBe(0);
expect(sent).toHaveLength(0);
expect(
updates.some((u) => u.sql.includes("reminder_sent_at"))
).toBe(false);
});
it("marks but does not remind a trial that already bought", async () => {
trialRows = [
{ fingerprint: UUID, email: "bought@example.com", started_at: daysAgo(12) }
];
licenseRows = [{ id: "l1", email: "bought@example.com", created_at: daysAgo(1) }];
const res = await GET(request("Bearer sekrit"));
const body = await res.json();
expect(body.reminders).toBe(0);
expect(sent).toHaveLength(0);
expect(
updates.some((u) => u.sql.includes("reminder_sent_at"))
).toBe(true);
});
it("claims via conditional update — a lost claim never sends", async () => {
// Simulate a racing invocation that already claimed the row: the
// conditional UPDATE reports rowsAffected 0, and no email goes out.
trialRows = [
{ fingerprint: UUID, email: "raced@example.com", started_at: daysAgo(12) }
];
licenseRows = [];
racingClaim = true;
const res = await GET(request("Bearer sekrit"));
const body = await res.json();
expect(body.reminders).toBe(0);
expect(sent).toHaveLength(0);
racingClaim = false;
});
it("asks for a testimonial at D+3 but not a review until D+14", async () => {
licenseRows = [
{
id: "l1",
email: "buyer@example.com",
created_at: daysAgo(5),
testimonial_asked_at: null,
review_asked_at: null
}
];
const res = await GET(request("Bearer sekrit"));
const body = await res.json();
expect(body.testimonials).toBe(1);
expect(body.reviews).toBe(0);
expect(sent).toEqual(["testimonial:buyer@example.com"]);
});
it("sends both asks for an old license and never twice", async () => {
licenseRows = [
{
id: "l1",
email: "buyer@example.com",
created_at: daysAgo(20),
testimonial_asked_at: null,
review_asked_at: null
}
];
const res = await GET(request("Bearer sekrit"));
const body = await res.json();
expect(body.testimonials).toBe(1);
expect(body.reviews).toBe(1);
expect(sent.sort()).toEqual([
"review:buyer@example.com",
"testimonial:buyer@example.com"
]);
// Second run: both columns are now set — nothing re-sends.
sent.length = 0;
licenseRows = [
{
id: "l1",
email: "buyer@example.com",
created_at: daysAgo(20),
testimonial_asked_at: daysAgo(1),
review_asked_at: daysAgo(1)
}
];
const res2 = await GET(request("Bearer sekrit"));
const body2 = await res2.json();
expect(body2.testimonials).toBe(0);
expect(body2.reviews).toBe(0);
expect(sent).toHaveLength(0);
});
});

View File

@@ -0,0 +1,136 @@
import type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap } from "~/server/nook";
import {
emailTrialReminder,
emailTestimonialAsk,
emailReviewAsk
} from "~/server/nook-lifecycle";
import { json } from "./_lib";
/**
* GET /api/the-nook/lifecycle — daily Vercel cron.
*
* Runs the scheduled half of the two lifecycle emails:
* - trials with an email and no reminder yet, aged ≥ 11 days → D+11 expiry
* reminder (skipped when that same address already bought a license)
* - licenses aged ≥ 3 days → testimonial ask
* - licenses aged ≥ 14 days → review ask
*
* Each row is marked *before* its send is attempted (best-effort, logged on
* failure), so a retry never double-sends; the purchase thank-you itself is
* sent from the Stripe webhook at purchase time. Auth: `Authorization:
* Bearer <secret>` where the secret is NOOK_LIFECYCLE_SECRET or Vercel's
* CRON_SECRET — an unauthenticated trigger could re-email real users, so an
* unconfigured secret fails closed.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
function ageDays(iso: string): number {
const t = Date.parse(iso);
return Number.isFinite(t) ? (Date.now() - t) / DAY_MS : -1;
}
async function runLifecycle(): Promise<{
reminders: number;
testimonials: number;
reviews: number;
}> {
await nookSchemaBootstrap;
const conn = NookConnectionFactory();
const counts = { reminders: 0, testimonials: 0, reviews: 0 };
// ── Trial D+11 reminder ────────────────────────────────────────────────
const trials = await conn.execute(
`SELECT fingerprint, email, started_at FROM trials
WHERE email IS NOT NULL AND reminder_sent_at IS NULL`
);
for (const row of trials.rows as {
fingerprint: string;
email: string;
started_at: string;
}[]) {
if (ageDays(row.started_at) < 11) continue;
// Claim-then-send: the conditional UPDATE wins exactly once even when
// two invocations race (cron + manual hit) — the loser sees
// rowsAffected 0 and never sends. An unconditional mark-then-send let
// both invocations pass the SELECT and mail the same user twice.
const claim = await conn.execute({
sql: `
UPDATE trials SET reminder_sent_at = ?
WHERE fingerprint = ? AND reminder_sent_at IS NULL
`,
args: [new Date().toISOString(), row.fingerprint]
});
if (claim.rowsAffected === 0) continue;
// A trial that ended in a purchase gets no buy reminder. The address
// comparison is on the raw stored values; both writes lowercase.
const owned = await conn.execute({
sql: "SELECT 1 FROM licenses WHERE lower(email) = lower(?) LIMIT 1",
args: [row.email]
});
if (owned.rows.length > 0) continue;
if (await emailTrialReminder(row.email)) counts.reminders++;
}
// ── Purchase D+3 testimonial ask / D+14 review ask ────────────────────
const licenses = await conn.execute(
`SELECT id, email, created_at, testimonial_asked_at, review_asked_at
FROM licenses WHERE revoked = 0`
);
for (const row of licenses.rows as {
id: string;
email: string;
created_at: string;
testimonial_asked_at: string | null;
review_asked_at: string | null;
}[]) {
const age = ageDays(row.created_at);
if (age < 0) continue;
if (age >= 3 && !row.testimonial_asked_at) {
const claim = await conn.execute({
sql: `
UPDATE licenses SET testimonial_asked_at = ?
WHERE id = ? AND testimonial_asked_at IS NULL
`,
args: [new Date().toISOString(), row.id]
});
if (claim.rowsAffected === 0) continue;
if (await emailTestimonialAsk(row.email)) counts.testimonials++;
}
if (age >= 14 && !row.review_asked_at) {
const claim = await conn.execute({
sql: `
UPDATE licenses SET review_asked_at = ?
WHERE id = ? AND review_asked_at IS NULL
`,
args: [new Date().toISOString(), row.id]
});
if (claim.rowsAffected === 0) continue;
if (await emailReviewAsk(row.email)) counts.reviews++;
}
}
return counts;
}
export async function GET(event: APIEvent) {
const auth = event.request.headers.get("authorization") ?? "";
const provided = auth.replace(/^Bearer\s+/i, "").trim();
const secret = env.NOOK_LIFECYCLE_SECRET ?? env.CRON_SECRET;
if (!secret || !provided || provided !== secret) {
return json({ error: "Unauthorized" }, 401);
}
try {
const counts = await runLifecycle();
return json({ success: true, ...counts });
} catch (error) {
console.error("Lifecycle job failed:", error);
return json({ error: "Lifecycle job failed" }, 500);
}
}

View File

@@ -0,0 +1,35 @@
import type { APIEvent } from "@solidjs/start/server";
import { collectReleases } from "./_releases";
import { error } from "./_lib";
/**
* GET /api/the-nook/releases.json
*
* The release list behind The Nook's Settings > Changelog tab, newest first:
* `{ releases: [{ version, build, date, sections: [{ title, bullets }] }] }`.
*
* Same source as /changelog — the Sparkle appcast plus the checked-in archive
* of releases older than its retention — so the app and the website tell one
* story. Section titles and ISO dates instead of HTML keep the client from
* parsing markup it would only take apart again.
*
* Five-minute cache, in step with the appcast route. An unreadable appcast is
* a 503 rather than an archived-only 200: the app keeps the newest release in
* its own bundle and falls back to it, which beats showing stale notes as if
* they were current.
*/
export async function GET(_event: APIEvent) {
try {
const releases = await collectReleases();
return new Response(JSON.stringify({ releases }), {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=300"
}
});
} catch (err) {
console.error("Failed to build the Nook release list:", err);
return error("Release list unavailable", 503);
}
}

View File

@@ -0,0 +1,298 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
// The contract The Nook's Settings > Changelog tab is built on:
// 1. One list, newest first, from the appcast plus the archive of releases
// the appcast has aged out — the same merge /changelog renders.
// 2. Structured notes and ISO dates, so the client renders instead of
// parsing HTML it would only take apart again.
// 3. An unreadable appcast is a 503, never an archived-only 200 that reads
// as current.
type Release = {
version: string;
build: string | null;
date: string | null;
sections: { title: string; bullets: string[] }[];
};
let appcastXml = "";
let s3ShouldThrow = false;
mock.module("~/env/server", () => ({
env: {
AWS_REGION: "us-east-1",
MY_AWS_ACCESS_KEY: "test-key",
MY_AWS_SECRET_KEY: "test-secret",
VITE_DOWNLOAD_BUCKET_STRING: "test-bucket"
}
}));
mock.module("@aws-sdk/client-s3", () => ({
S3Client: class {
async send() {
if (s3ShouldThrow) throw new Error("s3 unavailable");
return {
Body: appcastXml
? { transformToString: async () => appcastXml }
: null
};
}
},
GetObjectCommand: class {
input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
}));
const { GET } = await import("./releases.json");
const { invalidateAppcastCache } = await import("./_appcast");
const { isoDateOrNull, sectionsFromHtml } = await import("./_releases");
const { ARCHIVED_RELEASES } = await import("~/routes/nook/changelog-archive");
const item = (version: string, pubDate: string, notes: string) => `
<item>
<title>Version ${version}</title>
<pubDate>${pubDate}</pubDate>
<sparkle:version>${version === "0.6.3" ? "10" : "9"}</sparkle:version>
<sparkle:shortVersionString>${version}</sparkle:shortVersionString>
<description><![CDATA[${notes}]]></description>
<enclosure url="https://cdn.example/TheNook-${version}.dmg" type="application/octet-stream"/>
</item>`;
const NOTES =
"<p><b>Added</b></p><ul><li>Changelog tab in Settings.</li></ul>" +
"<p><b>Fixed</b></p><ul><li>Call actions press only their own button; &quot;End Call&quot; no longer matches &quot;Send&quot;.</li></ul>";
const appcast = (items: string) =>
`<?xml version="1.0"?><rss><channel>${items}</channel></rss>`;
const request = () =>
({
request: new Request("https://freno.me/api/the-nook/releases.json"),
nativeEvent: {}
}) as never;
async function body(): Promise<{ releases: Release[] }> {
const res = await GET(request());
return (await res.json()) as { releases: Release[] };
}
// The fixture appcast: two releases it lists live, newest first.
const LIVE = ["0.6.3", "0.6.2"];
beforeEach(() => {
appcastXml = appcast(
item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) +
item("0.6.2", "Thu, 24 Sep 2026 21:16:04 -0400", NOTES)
);
s3ShouldThrow = false;
invalidateAppcastCache();
});
describe("GET /api/the-nook/releases.json", () => {
it("serves the appcast releases newest first, cacheable for five minutes", async () => {
const res = await GET(request());
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
expect(res.headers.get("Cache-Control")).toBe("public, max-age=300");
const { releases } = await body();
expect(releases.map((r) => r.version)).toEqual([...LIVE, ...agedOut(...LIVE)]);
expect(releases[0].build).toBe("10");
});
it("splits the published notes into titled bullet groups", async () => {
const [first] = (await body()).releases;
expect(first!.sections).toEqual([
{ title: "Added", bullets: ["Changelog tab in Settings."] },
{
title: "Fixed",
bullets: [
'Call actions press only their own button; "End Call" no longer matches "Send".'
]
}
]);
});
it("dates a release on its own day, not the UTC one", async () => {
const releases = (await body()).releases;
// 23:29 -0400 is 03:29Z the next day; the release is still dated the 2nd.
expect(releases[0]!.date).toBe("2026-10-02");
expect(releases[1]!.date).toBe("2026-09-24");
});
it("appends the archived releases the appcast has aged out", async () => {
const releases = (await body()).releases;
const archived = releases.filter((r) => !LIVE.includes(r.version));
expect(archived.map((r) => r.version)).toEqual(agedOut(...LIVE));
expect(archived[0]!.build).toBeNull();
// Archive dates are already calendar dates and pass through untouched.
expect(archived[0]!.date).toBe(ARCHIVED_RELEASES[0]!.date);
expect(archived[0]!.sections.length).toBeGreaterThan(0);
});
it("lists a version once when the appcast and the archive both carry it", async () => {
appcastXml = appcast(item("0.5.0", "Mon, 07 Sep 2026 16:11:44 -0400", NOTES));
invalidateAppcastCache();
const releases = (await body()).releases;
const matches = releases.filter((r) => r.version === "0.5.0");
expect(matches.length).toBe(1);
// The live item wins: it carries the build number the archive lacks.
expect(matches[0]!.build).toBe("9");
expect(matches[0]!.date).toBe("2026-09-07");
});
it("503s when the appcast cannot be read, rather than serving archive-only notes", async () => {
s3ShouldThrow = true;
const res = await GET(request());
expect(res.status).toBe(503);
expect(((await res.json()) as { error: string }).error).toBe(
"Release list unavailable"
);
});
});
describe("notes the appcast lost", () => {
// Sparkle rebuilds every item from its DMG and removes the description of an
// item whose DMG carries no notes file, so a release the appcast still lists
// can arrive with nothing to show. 0.8.0 and 0.7.0 did exactly that, in both
// this feed and /changelog (NOOK-304): a version header over an empty row.
const itemWithoutNotes = (version: string) => `
<item>
<title>${version}</title>
<pubDate>Fri, 02 Oct 2026 23:29:42 -0400</pubDate>
<sparkle:version>9</sparkle:version>
<sparkle:shortVersionString>${version}</sparkle:shortVersionString>
<enclosure url="https://cdn.example/TheNook-${version}.dmg" type="application/octet-stream"/>
</item>`;
it("falls back to the archive's copy for a release listed without notes", async () => {
appcastXml = appcast(
item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) +
itemWithoutNotes("0.5.0")
);
invalidateAppcastCache();
const lost = (await body()).releases.find((r) => r.version === "0.5.0")!;
expect(lost.sections).toEqual(
sectionsFromHtml(
ARCHIVED_RELEASES.find((r) => r.version === "0.5.0")!.notesHtml
)
);
expect(lost.sections.length).toBeGreaterThan(0);
// Still the live row, not the archived one: it carries the build number.
expect(lost.build).toBe("9");
});
it("keeps the published notes when the archive has them too", async () => {
appcastXml = appcast(item("0.5.0", "Mon, 07 Sep 2026 16:11:44 -0400", NOTES));
invalidateAppcastCache();
const [release] = (await body()).releases;
expect(release!.sections).toEqual(sectionsFromHtml(NOTES));
});
});
describe("the checked-in archive", () => {
// The durable copy of what every release said. The appcast carries only the
// DMGs the release machine still holds, and it loses notes when it rebuilds,
// so a release missing here is a release the changelog can blank out later
// with nothing left to fall back to.
it("holds every release that shipped notes, newest first", () => {
expect(ARCHIVED_RELEASES.map((r) => r.version)).toEqual([
"0.9.0",
"0.8.0",
"0.7.0",
"0.6.3",
"0.6.2",
"0.6.0",
"0.5.0",
"0.4.1",
"0.4.0",
"0.3.0",
"0.2.0"
// 0.6.1 and 0.3.1 shipped without written notes and are absent by design.
]);
});
it("holds notes for every release it lists, and a date for each", () => {
for (const release of ARCHIVED_RELEASES) {
expect(
sectionsFromHtml(release.notesHtml).length,
`${release.version} has no notes`
).toBeGreaterThan(0);
expect(isoDateOrNull(release.date), release.version).not.toBeNull();
}
});
});
describe("sectionsFromHtml", () => {
it("labels an unlabelled list as Notes", () => {
expect(sectionsFromHtml("<ul><li>Fixes</li></ul>")).toEqual([
{ title: "Notes", bullets: ["Fixes"] }
]);
});
it("joins a bullet the HTML wrapped across lines, and drops empty ones", () => {
expect(
sectionsFromHtml(
"<p><b>Fixed</b></p><ul>\n<li>a bullet\n split over\n lines</li><li> </li></ul>"
)
).toEqual([{ title: "Fixed", bullets: ["a bullet split over lines"] }]);
});
it("returns no sections for empty or note-less HTML", () => {
expect(sectionsFromHtml("")).toEqual([]);
expect(sectionsFromHtml("<p><b>Added</b></p>")).toEqual([]);
});
it("decodes the entities embed_release_notes.py's html.escape writes", () => {
// Python's html.escape prefers the hex form for apostrophes; this is what
// the live appcast actually carries (NOOK-565).
expect(
sectionsFromHtml(
"<p><b>Added</b></p><ul><li>Each app&#x27;s meter &amp; the icon</li><li>don&#x27;t expand</li></ul>"
)
).toEqual([
{
title: "Added",
bullets: ["Each app's meter & the icon", "don't expand"]
}
]);
});
});
describe("isoDateOrNull", () => {
it("reads an RFC-1123 pubDate at its own offset", () => {
expect(isoDateOrNull("Sat, 03 Oct 2026 00:00:00 +0000")).toBe("2026-10-03");
expect(isoDateOrNull("Fri, 02 Oct 2026 23:29:42 -0400")).toBe("2026-10-02");
expect(isoDateOrNull("Tue, 06 Oct 2026 09:00:00 +0200")).toBe("2026-10-06");
});
it("passes a calendar date through and rejects junk", () => {
expect(isoDateOrNull("2026-09-06")).toBe("2026-09-06");
expect(isoDateOrNull("sometime in September")).toBeNull();
expect(isoDateOrNull("")).toBeNull();
expect(isoDateOrNull(null)).toBeNull();
});
});
/**
* The tail a case should expect: the whole archive in file order (newest
* first) minus the versions its fixture appcast lists live. A version is
* listed once, from the appcast, and the archive copy behind it only fills
* notes the feed lost — so the tail shrinks as the archive grows.
*/
const agedOut = (...live: string[]) =>
ARCHIVED_RELEASES.map((r) => r.version).filter((v) => !live.includes(v));

View File

@@ -5,10 +5,13 @@ import { describe, expect, it, mock, beforeEach } from "bun:test";
// and description, HTML-escaped.
// 2. Invalid payloads never reach the mailer.
// 3. The rate limiter halts floods per fingerprint (falling back to IP).
// 4. The landing page's iPhone beta request (email only) gets through.
// sendEmail and the limiter are mocked so the test sends no real mail.
const sentEmails: { to: string; subject: string; html: string }[] = [];
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
/** Every rate-limit key the route derived, in order. */
const rateKeys: string[] = [];
mock.module("~/server/email", () => ({
default: async (to: string, subject: string, html: string) => {
@@ -18,10 +21,16 @@ mock.module("~/server/email", () => ({
}));
mock.module("~/server/bug-report-rate-limit", () => ({
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true }
takeBugReportToken: (key: string) => {
rateKeys.push(key);
return tokenAnswers.shift() ?? { allowed: true };
}
}));
const { POST } = await import("./report");
const { betaRequestPayload } = await import(
"~/components/nook/betaRequest"
);
/** A minimal APIEvent double: JSON body plus optional headers. */
function request(body: unknown, headers: Record<string, string> = {}) {
@@ -68,6 +77,7 @@ describe("POST /api/the-nook/report", () => {
beforeEach(() => {
sentEmails.length = 0;
tokenAnswers.length = 0;
rateKeys.length = 0;
});
it("emails the owner a report containing the snapshot, escaped", async () => {
@@ -129,9 +139,61 @@ describe("POST /api/the-nook/report", () => {
it("still succeeds when no fingerprint header exists", async () => {
const res = await POST(
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" })
request(validPayload, {
"cf-connecting-ip": "203.0.113.9",
"x-forwarded-for": "203.0.113.9, 10.0.0.1"
})
);
expect(res.status).toBe(200);
expect(sentEmails).toHaveLength(1);
// The quota key is the Cloudflare-recorded client, not a header hop the
// sender chose.
expect(rateKeys).toEqual(["203.0.113.9"]);
});
it("a rotating leftmost x-forwarded-for does not mint a fresh quota key", async () => {
// FRE-371: the old read keyed on the leftmost hop, which the sender
// controls outright — one scripted loop with a rotating header had
// unlimited reports. With the same Cloudflare-recorded client and a
// different spoofed prefix per request, the key must be stable.
for (const spoof of ["1.1.1.1", "2.2.2.2", "3.3.3.3"]) {
const res = await POST(
request(validPayload, {
"cf-connecting-ip": "203.0.113.9",
"x-forwarded-for": `${spoof}, 203.0.113.9, 10.0.0.1`
})
);
expect(res.status).toBe(200);
}
expect(rateKeys).toEqual([
"203.0.113.9",
"203.0.113.9",
"203.0.113.9"
]);
});
it("keys on the rightmost-minus-one hop when Cloudflare's header is absent", async () => {
const res = await POST(
request(validPayload, {
"x-forwarded-for": "6.6.6.6, 203.0.113.9, 10.0.0.1"
})
);
expect(res.status).toBe(200);
// Never the leftmost (sender-chosen) hop.
expect(rateKeys).toEqual(["203.0.113.9"]);
});
it("delivers the landing page's beta request with only the email", async () => {
const res = await POST(request(betaRequestPayload("tester@example.com")));
expect(res.status).toBe(200);
expect(sentEmails).toHaveLength(1);
const email = sentEmails[0]!;
expect(email.subject).toBe(
"The Nook bug report: iPhone app beta access request"
);
expect(email.html).toContain("<b>Reply-to:</b> tester@example.com");
expect(email.html).not.toContain("<h3>Displays</h3>");
});
});

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import sendEmail from "~/server/email";
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
import { json, error } from "./_lib";
/**
@@ -103,10 +104,9 @@ export async function POST(event: APIEvent): Promise<Response> {
)
.trim()
.toLowerCase();
const forwarded = event.request.headers.get("x-forwarded-for");
const rateKey =
fingerprint ||
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
// FRE-371: the IP fallback must be the spoof-resistant read — a rotating
// leftmost x-forwarded-for used to mint unlimited report quota.
const rateKey = fingerprint || resolveTrustedClientIp(event);
const token = takeBugReportToken(rateKey);
if (!token.allowed) {
return new Response(JSON.stringify({ error: "Too many reports" }), {

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
// The route's contracts:
// 1. Both fingerprint (UUID) and email are validated — bad input never
// reaches the store.
// 2. The email is normalized (trimmed, lowercased) before storage.
// 3. The response is `{ success: true }` regardless of whether a trial row
// already existed — the client learns nothing it doesn't know.
// NookConnectionFactory and the nook helper module are mocked so the test
// needs no real DB, env, or outbound email.
const UUID = "a1b2c3d4-e5f6-4a1b-8c2d-1234567890ab";
const calls: { sql: string; args?: unknown[] }[] = [];
const fakeConn = {
execute: async (q: { sql: string; args?: unknown[] }) => {
calls.push(q);
return { rows: [], rowsAffected: 1 };
}
};
mock.module("~/server/db-connections", () => ({
NookConnectionFactory: () => fakeConn
}));
mock.module("~/server/nook", () => ({
// Full export surface: bun's mock registry is shared across test files in
// one run, and a partial mock here would starve resend-license.test.ts.
nookSchemaBootstrap: Promise.resolve(),
emailLicenseKey: async () => {},
issueLicense: async () => ({ key: "", id: "" }),
grantLicense: async () => ({ key: "", id: "" }),
setTrialEmail: async (fingerprint: string, email: string) => {
calls.push({ sql: "setTrialEmail", args: [fingerprint, email] });
return true;
},
verifyLicenseKey: () => true
}));
const { POST } = await import("./trial-email");
function request(body: unknown): any {
return { request: { json: async () => body } };
}
describe("POST /api/the-nook/trial-email", () => {
beforeEach(() => {
calls.length = 0;
});
it("rejects an invalid fingerprint", async () => {
const res = await POST(
request({ fingerprint: "nope", email: "a@b.co" })
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid fingerprint" });
});
it("rejects an invalid email", async () => {
const res = await POST(
request({ fingerprint: UUID, email: "not-an-email" })
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "Invalid email" });
expect(calls).toHaveLength(0);
});
it("normalizes the email and stores it against the fingerprint", async () => {
const res = await POST(
request({ fingerprint: UUID, email: " USER@Example.COM " })
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true });
const stored = calls.find((c) => c.sql === "setTrialEmail");
expect(stored?.args).toEqual([UUID, "user@example.com"]);
});
});

View File

@@ -0,0 +1,36 @@
import type { APIEvent } from "@solidjs/start/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, setTrialEmail } from "~/server/nook";
import { json, error, isUuid } from "./_lib";
/**
* POST /api/the-nook/trial-email
* Body: { fingerprint, email }
*
* Attaches the email a user gave at trial start (the optional first-launch
* prompt) to their `trials` row and adds it to the Brevo trial list. The
* response is the same whether the trial row existed or not, so the client
* learns nothing it doesn't already know.
*/
export async function POST(event: APIEvent) {
let body: unknown;
try {
body = await event.request.json();
} catch {
return error("Invalid JSON", 400);
}
const b = (body ?? {}) as Record<string, unknown>;
const fingerprint = b.fingerprint;
if (!isUuid(fingerprint)) {
return error("Invalid fingerprint", 400);
}
const email =
typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
return error("Invalid email", 400);
}
await nookSchemaBootstrap;
const stored = await setTrialEmail(fingerprint, email);
return json({ success: stored });
}

View File

@@ -2,6 +2,7 @@ import type { APIEvent } from "@solidjs/start/server";
import { env } from "~/env/server";
import { NookConnectionFactory } from "~/server/db-connections";
import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
import { emailThankYou } from "~/server/nook-lifecycle";
import { json } from "../_lib";
import { createHmac, timingSafeEqual } from "node:crypto";
@@ -92,6 +93,9 @@ export async function POST(event: APIEvent) {
try {
const { key } = await issueLicense(email, sessionId);
await emailLicenseKey(email, key);
// Purchase thank-you (the D+3 testimonial and D+14 review asks are
// scheduled from the daily lifecycle job, not here).
await emailThankYou(email);
} catch (error) {
// UNIQUE stripe_session_id conflict from a racing duplicate delivery.
console.error("Failed to issue The Nook license (webhook):", error);

View File

@@ -7,6 +7,7 @@ import {
useSearchParams
} from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web";
import AuthenticatedLike from "~/components/blog/AuthenticatedLike";
@@ -20,6 +21,8 @@ import { api } from "~/lib/api";
import CustomScrollbar from "~/components/CustomScrollbar";
import "../post.css";
import { Post } from "~/db/types";
import { useSite } from "~/context/SiteContext";
import NotFound from "../../[...404]";
const getPostByTitle = query(
async (
@@ -261,6 +264,7 @@ const getPostByTitle = query(
);
export default function PostPage() {
const site = useSite();
const params = useParams();
const [searchParams] = useSearchParams();
@@ -296,6 +300,12 @@ export default function PostPage() {
return str.includes('data-type="mermaid"');
};
// Host-dispatch guard: blog posts live on the apex domain only (see the
// guard in blog/index.tsx for the dev-server rationale).
if (site().id !== "main") {
return <NotFound />;
}
return (
<Show
when={data()}
@@ -327,6 +337,7 @@ export default function PostPage() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<PageHead
title={p().title.replaceAll("_", " ")}
description={

View File

@@ -1,6 +1,7 @@
import { Show } from "solid-js";
import { useSearchParams, A, query } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { createAsync } from "@solidjs/router";
import PostSortingSelect from "~/components/blog/PostSortingSelect";
import TagSelector from "~/components/blog/TagSelector";
@@ -8,6 +9,8 @@ import PostSorting from "~/components/blog/PostSorting";
import PublishStatusToggle from "~/components/blog/PublishStatusToggle";
import { TerminalSplash } from "~/components/TerminalSplash";
import { CACHE_CONFIG } from "~/config";
import { useSite } from "~/context/SiteContext";
import NotFound from "../[...404]";
const getPosts = query(async () => {
"use server";
@@ -76,6 +79,7 @@ const getPosts = query(async () => {
}, "posts");
export default function BlogIndex() {
const site = useSite();
const [searchParams] = useSearchParams();
const sort = () => searchParams.sort || "newest";
@@ -88,8 +92,18 @@ export default function BlogIndex() {
const data = createAsync(() => getPosts(), { deferStream: true });
// Host-dispatch guard: /blog is the personal blog on the apex domain only.
// Production rewrites already send `<sub>.freno.me/blog` → `/<sub>/blog`,
// which has no route — but in dev the vinxi server ignores vercel.json and
// would render the personal blog on a product subdomain, splitting the
// brand and crawl equity. Render the shared 404 instead.
if (site().id !== "main") {
return <NotFound />;
}
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<PageHead
title="Blog"
description="Technical blog posts about web development, programming, and software engineering."

23
src/routes/changelog.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NookChangelogPage from "./nook/changelog";
/**
* Host-aware changelog route — `/changelog`.
*
* The release-notes page is a Nook-subdomain surface; all other sites fall
* back to 404. Mirrors the dispatch pattern in `src/routes/index.tsx` and
* `src/routes/checkout.tsx` — production does not apply the vercel.json
* host rewrites (SSR sees `/changelog`, never `/nook/changelog`).
*/
export default function ChangelogPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nook"}>
<NookChangelogPage />
</Match>
</Switch>
);
}

View File

@@ -3,6 +3,7 @@ import { useSearchParams } from "@solidjs/router";
import { A } from "@solidjs/router";
import RevealDropDown from "~/components/RevealDropDown";
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { buildSubdomainUrl } from "~/lib/site-context";
import { useSite } from "~/context/SiteContext";
import NessaContactPage from "./nessa/contact";
@@ -125,7 +126,9 @@ function MainContactPage() {
const viewer = () => searchParams.viewer ?? "default";
return (
<ContactForm
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<ContactForm
subline={
<Show when={viewer() !== "lineage"}>
(for this website or any of my apps...)
@@ -134,6 +137,7 @@ function MainContactPage() {
>
<LineageContactQuestions />
</ContactForm>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { A } from "@solidjs/router";
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
@@ -71,6 +72,7 @@ function MainDownloadsPage() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Downloads"
description="Download The Nook, InputHalo, Gaze, Life and Lineage, Shapes with Abigail, and Cork. Available on macOS, iOS, and Android."
@@ -108,7 +110,7 @@ function MainDownloadsPage() {
<div class="flex flex-col items-center gap-3">
<span class="text-subtext0 font-mono text-sm">
platform: macOS (14.6+)
platform: macOS (14.2+)
</span>
<Button
variant="download"

23
src/routes/faq.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Switch, Match } from "solid-js";
import { useSite } from "~/context/SiteContext";
import NotFound from "./[...404]";
import NookFaqPage from "./nook/faq";
/**
* Host-aware FAQ route — `/faq`.
*
* The FAQ is a Nook-subdomain surface; all other sites fall back to 404.
* Mirrors the dispatch pattern in `src/routes/index.tsx` and
* `src/routes/checkout.tsx` — production does not apply the vercel.json
* host rewrites (SSR sees `/faq`, never `/nook/faq`).
*/
export default function FaqPage() {
const site = useSite();
return (
<Switch fallback={<NotFound />}>
<Match when={site().id === "nook"}>
<NookFaqPage />
</Match>
</Switch>
);
}

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
/**
@@ -19,6 +20,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
export default function GazeContactPage() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader />
<ContactForm />
</>

View File

@@ -11,6 +11,7 @@
import { A } from "@solidjs/router";
import { createSignal } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
@@ -48,6 +49,7 @@ export default function GazeDownloadsPage() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Download Gaze"
description="Download Gaze for macOS — menu bar app for eye and posture health."

View File

@@ -1,5 +1,6 @@
import { createSignal, For } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
@@ -60,6 +61,7 @@ export default function GazeLanding() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Home"
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."

View File

@@ -15,11 +15,13 @@
*/
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
export default function GazePrivacyPolicy() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Privacy Policy"
description="Privacy policy for Gaze, a macOS eye health reminder app."

View File

@@ -1,8 +1,10 @@
import { Switch, Match, type JSX } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import { DarkModeToggle } from "~/components/DarkModeToggle";
import { Typewriter } from "~/components/Typewriter";
import { useSite } from "~/context/SiteContext";
import { buildSubdomainUrl } from "~/lib/site-context";
import NessaLanding from "./nessa";
import LineageLanding from "./lineage";
import GazeLanding from "./gaze";
@@ -56,6 +58,7 @@ export default function Home(): JSX.Element {
function MainHome(): JSX.Element {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Home"
description="Michael Freno - Software Engineer based in Brooklyn, NY"
@@ -101,6 +104,32 @@ function MainHome(): JSX.Element {
</Typewriter>
<div class="pt-8 text-center">
<div class="pb-4">Some of my recent projects:</div>
{/* The Nook */}
<div class="border-surface0 mb-2 flex w-full flex-col gap-2 rounded-md border-2 p-4 text-center">
<div>My macOS notch utility:</div>
<a
href={buildSubdomainUrl("nook")}
class="text-blue hover-underline-animation mx-auto w-fit"
>
The Nook
</a>
<div class="mx-auto w-full max-w-4xl overflow-hidden rounded-lg">
<video
src="/nook/demo-expansion.mp4"
class="h-full w-full object-cover"
autoplay
loop
muted
playsinline
/>
</div>
<div class="pt-2 text-left text-sm">
A native macOS island that lives in the notch: orchestration for
a fleet of coding agents, fan curve control, thermal gauges,
calendar and camera modules. One-time purchase - no
subscription, ever.
</div>
</div>
<div class="flex flex-col items-center gap-2 xl:flex-row xl:items-start xl:justify-center">
{/* FlexLöve */}
<div class="border-surface0 flex w-full flex-col rounded-md border-2 p-4 text-center">

View File

@@ -1,4 +1,5 @@
import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
/**
@@ -19,6 +20,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
export default function InputHaloContactPage() {
return (
<>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader />
<ContactForm />
</>

View File

@@ -11,6 +11,7 @@
import { A } from "@solidjs/router";
import { createSignal } from "solid-js";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
import Button from "~/components/ui/Button";
@@ -51,6 +52,7 @@ export default function InputHaloDownloadsPage() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Download InputHalo"
description="Download InputHalo for macOS — menu bar app for keyboard, mouse, and scroll visualization."

View File

@@ -13,6 +13,7 @@
import { For, createSignal } from "solid-js";
import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader";
import Button from "~/components/ui/Button";
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
@@ -98,6 +99,7 @@ export default function InputHaloLanding() {
return (
<>
<EdgeCacheHeaders maxAge={300} />
<PageHead
title="Home"
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."

Some files were not shown because too many files have changed in this diff Show More