Compare commits
76 Commits
22f40b2284
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12a063aa9 | ||
|
|
0137703341 | ||
|
|
4dac2e2928 | ||
|
|
680563e3f9 | ||
|
|
c5fb98af8d | ||
|
|
77e692273e | ||
|
|
a58b25501a | ||
| a0e6ff1044 | |||
| 6fd0f84806 | |||
| c7071b0e32 | |||
| 7fde259e0b | |||
| 25ec84ceb4 | |||
| dd368d21e7 | |||
| 2fd2a4a71a | |||
|
|
1d9287a199 | ||
|
|
39d874a7e2 | ||
|
|
89f56fc78a | ||
|
|
d44133fcb3 | ||
|
|
578ee58f9c | ||
|
|
d2c2e6a4ee | ||
|
|
c718370674 | ||
|
|
210a7e6e75 | ||
|
|
c1c498e37f | ||
| 134f41eac4 | |||
| 881190d1d4 | |||
| f492132bf2 | |||
| a524f23be2 | |||
| 0bf15e528d | |||
| a48326fd1d | |||
| ac63057ef7 | |||
| f6d6d442de | |||
| 14a61fde9b | |||
| b4e68765fe | |||
| dabfeada3b | |||
| 9508aee8e1 | |||
| 1a182e53fe | |||
| 6898186df5 | |||
| 9ff095a82c | |||
| be90b3d068 | |||
| cbe56390f8 | |||
| 26c37c1862 | |||
| 7108f77bf9 | |||
| 687df016a6 | |||
| 92af52dfc9 | |||
| 53f1413966 | |||
| 6f41436591 | |||
| 0b128f6639 | |||
| 37a39b59fe | |||
| ba6b478320 | |||
| 35986239c1 | |||
| b41cf83879 | |||
| f66fd43f62 | |||
| 421fb946fc | |||
| dc959d971b | |||
| 44bf21a868 | |||
| dd63da30c4 | |||
| 2fb5097b85 | |||
| 96e401ecbb | |||
| 3fe3516b9e | |||
| bef8f8414b | |||
| f3e9cdf0bd | |||
| 7ecc0f68d7 | |||
| 00a26a9e49 | |||
| 163c68fcb8 | |||
| 3149eba196 | |||
| bc65789431 | |||
| 21f1f07a99 | |||
| 23f86681af | |||
| 8492821f75 | |||
| 17bb43851e | |||
| fd68efdb78 | |||
| 1ee61d8fe4 | |||
| 6c024342ba | |||
| 9860213760 | |||
| cb584f8b68 | |||
| 8c5fe25c2b |
17
.env.example
17
.env.example
@@ -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_DB_API_TOKEN="<rotate-in-turso-dashboard>" # org-level API token
|
||||||
TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io"
|
TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io"
|
||||||
TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>"
|
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_LINEAGE=com...
|
||||||
APPLE_CLIENT_ID_NESSA=com...
|
|
||||||
|
|
||||||
# ── Infra / integration tokens ──
|
# ── Infra / integration tokens ──
|
||||||
INFILL_BEARER_TOKEN="<rotate-at-infill-service>"
|
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_SK="sk_live_<rotate-in-stripe-dashboard>"
|
||||||
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
||||||
NOOK_STRIPE_PRICE_ID="price_<from-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=""
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,3 +34,4 @@ Thumbs.db
|
|||||||
# pygienium run-state and check artifacts
|
# pygienium run-state and check artifacts
|
||||||
.pygienium/
|
.pygienium/
|
||||||
scripts/
|
scripts/
|
||||||
|
.cache
|
||||||
|
|||||||
@@ -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.
|
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.
|
- **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).
|
- **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.
|
- **Auth:** Host-scoped only — no cookie domain broadening.
|
||||||
|
|||||||
@@ -10,21 +10,7 @@ export default defineConfig({
|
|||||||
org: "mikefreno",
|
org: "mikefreno",
|
||||||
project: "freno-dev",
|
project: "freno-dev",
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
telemetry: false,
|
telemetry: false
|
||||||
sourcemaps: {
|
|
||||||
assets: [
|
|
||||||
{
|
|
||||||
type: "bundle",
|
|
||||||
path: "dist/client/assets/",
|
|
||||||
urlPrefix: "~/assets/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "sourcemap",
|
|
||||||
path: "dist/client/assets/",
|
|
||||||
urlPrefix: "~/assets/"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
33
marketing/README.md
Normal file
33
marketing/README.md
Normal 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.
|
||||||
1
marketing/approved/.gitkeep
Normal file
1
marketing/approved/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# approved
|
||||||
92
marketing/approved/agent-done-signal/copy.md
Normal file
92
marketing/approved/agent-done-signal/copy.md
Normal 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.
|
||||||
72
marketing/approved/agent-done-signal/rationale.md
Normal file
72
marketing/approved/agent-done-signal/rationale.md
Normal 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.
|
||||||
113
marketing/approved/agent-parallel-tracking/copy.md
Normal file
113
marketing/approved/agent-parallel-tracking/copy.md
Normal 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.
|
||||||
71
marketing/approved/agent-parallel-tracking/rationale.md
Normal file
71
marketing/approved/agent-parallel-tracking/rationale.md
Normal 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.
|
||||||
102
marketing/approved/agent-run-without-asking/copy.md
Normal file
102
marketing/approved/agent-run-without-asking/copy.md
Normal 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.
|
||||||
75
marketing/approved/agent-run-without-asking/rationale.md
Normal file
75
marketing/approved/agent-run-without-asking/rationale.md
Normal 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.
|
||||||
86
marketing/approved/agent-waiting-on-permission/copy.md
Normal file
86
marketing/approved/agent-waiting-on-permission/copy.md
Normal 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.
|
||||||
77
marketing/approved/agent-waiting-on-permission/rationale.md
Normal file
77
marketing/approved/agent-waiting-on-permission/rationale.md
Normal 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.
|
||||||
1
marketing/briefs/.gitkeep
Normal file
1
marketing/briefs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# briefs
|
||||||
91
marketing/briefs/registry-wave-2.md
Normal file
91
marketing/briefs/registry-wave-2.md
Normal 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
4
marketing/calendar.json
Normal 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": []
|
||||||
|
}
|
||||||
1
marketing/drafts/.gitkeep
Normal file
1
marketing/drafts/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# drafts
|
||||||
96
marketing/drafts/agent-keeps-asking/copy.md
Normal file
96
marketing/drafts/agent-keeps-asking/copy.md
Normal 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.
|
||||||
52
marketing/drafts/agent-keeps-asking/rationale.md
Normal file
52
marketing/drafts/agent-keeps-asking/rationale.md
Normal 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).
|
||||||
72
marketing/drafts/agent-worktree-contention/copy.md
Normal file
72
marketing/drafts/agent-worktree-contention/copy.md
Normal 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.
|
||||||
46
marketing/drafts/agent-worktree-contention/rationale.md
Normal file
46
marketing/drafts/agent-worktree-contention/rationale.md
Normal 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).
|
||||||
96
marketing/drafts/newsletter-postmortem-pitch/copy.md
Normal file
96
marketing/drafts/newsletter-postmortem-pitch/copy.md
Normal 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.
|
||||||
73
marketing/drafts/newsletter-postmortem-pitch/rationale.md
Normal file
73
marketing/drafts/newsletter-postmortem-pitch/rationale.md
Normal 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.
|
||||||
79
marketing/drafts/remote-ssh/copy.md
Normal file
79
marketing/drafts/remote-ssh/copy.md
Normal 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.
|
||||||
30
marketing/drafts/remote-ssh/rationale.md
Normal file
30
marketing/drafts/remote-ssh/rationale.md
Normal 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
15
marketing/listening.md
Normal 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 |
|
||||||
|
|---|---|---|---|---|
|
||||||
43
marketing/metrics/weekly.md
Normal file
43
marketing/metrics/weekly.md
Normal 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.
|
||||||
1
marketing/pending/.gitkeep
Normal file
1
marketing/pending/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# pending
|
||||||
156
marketing/positioning.md
Normal file
156
marketing/positioning.md
Normal 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.
|
||||||
1
marketing/posted/.gitkeep
Normal file
1
marketing/posted/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# posted
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "example-with-trpc",
|
"name": "freno.(dev/me)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vinxi dev",
|
"dev": "vinxi dev",
|
||||||
@@ -11,12 +11,13 @@
|
|||||||
"test:watch": "bun test --watch",
|
"test:watch": "bun test --watch",
|
||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
"perf": "bun run scripts/perf-test.ts",
|
"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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.953.0",
|
"@aws-sdk/client-s3": "^3.953.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
||||||
"@clerk/backend": "^3.12.0",
|
|
||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@sentry/solidstart": "^10.67.0",
|
"@sentry/solidstart": "^10.67.0",
|
||||||
"@solidjs/meta": "^0.29.4",
|
"@solidjs/meta": "^0.29.4",
|
||||||
@@ -54,7 +55,6 @@
|
|||||||
"motion": "^12.23.26",
|
"motion": "^12.23.26",
|
||||||
"solid-js": "^1.9.5",
|
"solid-js": "^1.9.5",
|
||||||
"solid-tiptap": "^0.8.0",
|
"solid-tiptap": "^0.8.0",
|
||||||
"svix": "^1.98.0",
|
|
||||||
"ua-parser-js": "^2.0.7",
|
"ua-parser-js": "^2.0.7",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vinxi": "^0.5.7",
|
"vinxi": "^0.5.7",
|
||||||
|
|||||||
4
public/nook/brands/claude.svg
Normal file
4
public/nook/brands/claude.svg
Normal 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 |
4
public/nook/brands/codex.svg
Normal file
4
public/nook/brands/codex.svg
Normal 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 |
BIN
public/nook/cam-recording.mp4
Normal file
BIN
public/nook/cam-recording.mp4
Normal file
Binary file not shown.
BIN
public/nook/demo-expansion.mp4
Normal file
BIN
public/nook/demo-expansion.mp4
Normal file
Binary file not shown.
BIN
public/nook/og.png
Normal file
BIN
public/nook/og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -1,9 +1,21 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /blog
|
Allow: /blog
|
||||||
Allow: /projects
|
|
||||||
Disallow: /login
|
# Private / dynamic paths — not for indexing.
|
||||||
Disallow: /debug/
|
# 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: /databaseMGMT
|
||||||
|
Disallow: /debug/
|
||||||
|
Disallow: /error-test
|
||||||
|
Disallow: /login
|
||||||
|
Disallow: /success
|
||||||
|
Disallow: /test
|
||||||
|
|
||||||
Sitemap: https://www.freno.me/sitemap.xml
|
Sitemap: https://www.freno.me/sitemap.xml
|
||||||
|
|||||||
21
scripts/backfill-downloads.ts
Normal file
21
scripts/backfill-downloads.ts
Normal 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
38
scripts/upload-appcast.ts
Normal 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
159
scripts/weekly-metrics.ts
Normal 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);
|
||||||
|
});
|
||||||
292
src/app.css
292
src/app.css
@@ -574,3 +574,295 @@ a.hover-underline-animation:hover::after {
|
|||||||
.shaker:hover {
|
.shaker:hover {
|
||||||
animation: shaker 0.5s ease;
|
animation: shaker 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Nook landing: campfire sprite frame cycling ──────────────────── */
|
||||||
|
/* Each frame layer is stacked; its animation holds opacity 1 during its
|
||||||
|
slot and 0 otherwise, so the stack reads as stop-motion at the app's
|
||||||
|
sprite cadence (frame time = total duration / frame count). The phase
|
||||||
|
lives inside the keyframes and every layer animates delay-free from the
|
||||||
|
same clock WebKit quantizes animation starts per cycle wrap, so
|
||||||
|
delay-offset layers can drop a wrap frame and show BOTH transparent
|
||||||
|
for a frame (the "blank frame" flicker). Complementary holds mean any
|
||||||
|
moment sums to exactly one opaque layer. Linear timing: steps()
|
||||||
|
holds mis-swap at boundary frames under WebKit. */
|
||||||
|
/* 2-frame sprites: layer i uses campfire-slot-2-{a,b}. */
|
||||||
|
@keyframes campfire-slot-2-a {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes campfire-slot-2-b {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6-frame sprites (question): six slot phases. */
|
||||||
|
@keyframes campfire-slot-6-a {
|
||||||
|
0%,
|
||||||
|
16.66% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
16.67%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-b {
|
||||||
|
0%,
|
||||||
|
16.66% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
16.67%,
|
||||||
|
33.32% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
33.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-c {
|
||||||
|
0%,
|
||||||
|
33.32% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
33.33%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-d {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
66.66% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
66.67%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-e {
|
||||||
|
0%,
|
||||||
|
66.66% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
66.67%,
|
||||||
|
83.32% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
83.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-f {
|
||||||
|
0%,
|
||||||
|
83.32% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
83.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame {
|
||||||
|
animation: var(--slot-kf, campfire-slot-2-a) var(--campfire-duration, 1s)
|
||||||
|
linear infinite;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nook landing: breakdown section reveal ───────────────────────── */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
transition:
|
||||||
|
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal.in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame + .campfire-frame {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ── Nook landing: fan card shake, builds with fan speed ───────── */
|
||||||
|
.fan-card-shake {
|
||||||
|
transform-origin: left center;
|
||||||
|
animation-name: fan-shake;
|
||||||
|
animation-duration: var(--fan-speed, 0.4s);
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fan-shake {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(calc(var(--fan-amp, 0deg) * -1)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sputtering sparks off the fan card at max speed */
|
||||||
|
.fan-spark {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #ffd23f;
|
||||||
|
box-shadow: 0 0 4px 1px rgba(255, 140, 0, 0.9);
|
||||||
|
animation: fan-spark-fly var(--sd, 0.5s) ease-out var(--sdel, 0s) infinite;
|
||||||
|
}
|
||||||
|
@keyframes fan-spark-fly {
|
||||||
|
0% { transform: translate(0, 0) scale(1); opacity: 0; }
|
||||||
|
10% { opacity: 1; }
|
||||||
|
100% { transform: translate(var(--sx, 20px), var(--sy, 30px)) scale(0.4); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Demo pulse: battery bolt */
|
||||||
|
@keyframes nook-pulse {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -366,6 +366,11 @@ function MainRightBarContent() {
|
|||||||
Contact Me
|
Contact Me
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
|
||||||
|
<a href="/privacy-policy" onClick={handleLinkClick}>
|
||||||
|
Privacy
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/MikeFreno/"
|
href="https://github.com/MikeFreno/"
|
||||||
|
|||||||
29
src/components/EdgeCacheHeaders.tsx
Normal file
29
src/components/EdgeCacheHeaders.tsx
Normal 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}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -132,12 +132,17 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolvePageHeadMeta — OpenGraph fallbacks", () => {
|
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, "/");
|
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(
|
const meta = resolvePageHeadMeta(
|
||||||
{ ...BASE_PROPS, ogImage: "https://cdn/custom.png" },
|
{ ...BASE_PROPS, ogImage: "https://cdn/custom.png" },
|
||||||
SITE_CONFIG.main,
|
SITE_CONFIG.main,
|
||||||
@@ -146,6 +151,15 @@ describe("resolvePageHeadMeta — OpenGraph fallbacks", () => {
|
|||||||
expect(meta.ogImage).toBe("https://cdn/custom.png");
|
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)", () => {
|
it("ogTitle falls back to the base title (no suffix)", () => {
|
||||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
||||||
expect(meta.ogTitle).toBe("Blog");
|
expect(meta.ogTitle).toBe("Blog");
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export default function PageHead(props: PageHeadProps) {
|
|||||||
<Meta property="og:description" content={meta().ogDescription} />
|
<Meta property="og:description" content={meta().ogDescription} />
|
||||||
)}
|
)}
|
||||||
<Meta property="og:image" content={meta().ogImage} />
|
<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" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,16 @@ import { buildMainSiteUrl } from "~/lib/site-context";
|
|||||||
export default function SubdomainFooter() {
|
export default function SubdomainFooter() {
|
||||||
return (
|
return (
|
||||||
<footer class="border-surface0 bg-surface0 relative z-10 border-t py-8">
|
<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
|
<A
|
||||||
href={buildMainSiteUrl()}
|
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"><3</span>
|
made with <span class="text-red-400"><3</span>
|
||||||
</A>
|
</A>
|
||||||
<A
|
<A
|
||||||
href={buildMainSiteUrl("/downloads")}
|
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
|
see more products
|
||||||
</A>
|
</A>
|
||||||
|
|||||||
367
src/components/nook/Campfire.tsx
Normal file
367
src/components/nook/Campfire.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import { For, type JSX } from "solid-js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pixel-art campfire sprite, faithful to the app's CampfireFrames: a 14×14
|
||||||
|
* grid where the log base never moves and only the flame and sparks animate.
|
||||||
|
* `state` picks the frame set and cadence — idle simmers, running dances,
|
||||||
|
* ready exhales, error pulses dead embers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FIRE_PALETTE: Record<string, string> = {
|
||||||
|
o: "#e7873a",
|
||||||
|
y: "#ffce70",
|
||||||
|
w: "#ffffff",
|
||||||
|
b: "#8b5a2b",
|
||||||
|
d: "#5a3a1b",
|
||||||
|
s: "#ffd166",
|
||||||
|
r: "#c43a30",
|
||||||
|
R: "#f25240",
|
||||||
|
x: "#ff7864",
|
||||||
|
m: "rgba(255,255,255,0.5)",
|
||||||
|
k: "rgba(255,255,255,0.26)",
|
||||||
|
e: "#a8582a",
|
||||||
|
E: "#d87636",
|
||||||
|
B: "#4890fc",
|
||||||
|
L: "#92c7ff",
|
||||||
|
C: "#e9f8ff",
|
||||||
|
S: "#bfe0ff"
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CampfireFrame {
|
||||||
|
rows: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".....oyo......",
|
||||||
|
"....oyyyo.....",
|
||||||
|
"..dddeeeeeEd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".........k....",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"......oyo.....",
|
||||||
|
"....oyyyo.....",
|
||||||
|
"..dddEEEEEEd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const RUN_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".....s.yy.....",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oywwyo....",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"....oywwyo....",
|
||||||
|
"..dddddddddd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
".....s........",
|
||||||
|
".....yy.......",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oywwyo....",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"...oyywwyyo...",
|
||||||
|
"....oywwyo....",
|
||||||
|
"..dddddddddd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const READY_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"......yy......",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"..dddyyyyyyd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"........m.....",
|
||||||
|
".....yy.......",
|
||||||
|
".....oyyo.....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"....oyyyyo....",
|
||||||
|
"..dddyyyyyyd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/* App CampfireFrames.ques0-5: cool blue flame, tip dips, sparks spit. */
|
||||||
|
const QUES_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".....S.LL.....",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
".....S........",
|
||||||
|
".....LL.......",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
".....S........",
|
||||||
|
"......LL......",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
".....S........",
|
||||||
|
"..............",
|
||||||
|
"......LL......",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
".....S........",
|
||||||
|
"..............",
|
||||||
|
".......LL.....",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"........S.....",
|
||||||
|
"..............",
|
||||||
|
".......LL.....",
|
||||||
|
".....BLLB.....",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"...BLLCCLLB...",
|
||||||
|
"....BLCCLB....",
|
||||||
|
"..dddccccccd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const ERROR_FRAMES: CampfireFrame[] = [
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..dddrrrrrrd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rows: [
|
||||||
|
"........x.....",
|
||||||
|
"..............",
|
||||||
|
".....x........",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
"..dddRRRRRRd..",
|
||||||
|
"..dbbbbbbbbd..",
|
||||||
|
"..............",
|
||||||
|
"..............",
|
||||||
|
".............."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export type CampfireState = "idle" | "running" | "ready" | "question" | "error";
|
||||||
|
|
||||||
|
const STATE_FRAMES: Record<CampfireState, CampfireFrame[]> = {
|
||||||
|
idle: IDLE_FRAMES,
|
||||||
|
running: RUN_FRAMES,
|
||||||
|
ready: READY_FRAMES,
|
||||||
|
question: QUES_FRAMES,
|
||||||
|
error: ERROR_FRAMES
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_FPS: Record<CampfireState, number> = {
|
||||||
|
idle: 2,
|
||||||
|
running: 9,
|
||||||
|
ready: 4,
|
||||||
|
question: 4.5,
|
||||||
|
error: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Campfire(props: { state: CampfireState; pixel?: number }) {
|
||||||
|
const pixel = () => props.pixel ?? 3;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="animate-campfire grid"
|
||||||
|
style={{
|
||||||
|
"grid-template-columns": `repeat(14, ${pixel()}px)`,
|
||||||
|
/* One full cycle: every frame exactly one frame-interval long. */
|
||||||
|
"--campfire-duration": `${(1000 * STATE_FRAMES[props.state].length) / STATE_FPS[props.state] / 1000}s`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Slot phase lives in the keyframes per layer (campfire-slot-N-x);
|
||||||
|
delay-offset layers blank a wrap frame under WebKit. */}
|
||||||
|
<For each={STATE_FRAMES[props.state]}>
|
||||||
|
{(frame, i) => {
|
||||||
|
const n = STATE_FRAMES[props.state].length;
|
||||||
|
const phase = String.fromCharCode(97 + (i() % 26));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="campfire-frame col-span-full row-start-1"
|
||||||
|
style={{ "--slot-kf": `campfire-slot-${n}-${phase}` }}
|
||||||
|
>
|
||||||
|
<For each={frame.rows}>
|
||||||
|
{(row) => (
|
||||||
|
<div class="flex" style={{ height: `${pixel()}px` }}>
|
||||||
|
<For each={row.split("")}>
|
||||||
|
{(ch) => (
|
||||||
|
<span
|
||||||
|
class="inline-block"
|
||||||
|
style={{
|
||||||
|
width: `${pixel()}px`,
|
||||||
|
height: `${pixel()}px`,
|
||||||
|
background:
|
||||||
|
ch === "." ? "transparent" : FIRE_PALETTE[ch]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1006
src/components/nook/FeatureBreakdowns.tsx
Normal file
1006
src/components/nook/FeatureBreakdowns.tsx
Normal file
File diff suppressed because it is too large
Load Diff
250
src/components/nook/FeatureCollage.tsx
Normal file
250
src/components/nook/FeatureCollage.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
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
|
||||||
|
* shows the real collapsed pill (campfire slot, camera housing, live data)
|
||||||
|
* dressed in the island's black surface; supporting cells reuse the same
|
||||||
|
* module chrome, status palette, and metric language.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function CollageCell(props: {
|
||||||
|
class?: string;
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`border-overlay1 bg-surface1 relative flex flex-col overflow-hidden rounded-2xl border p-6 ${props.class ?? ""}`}
|
||||||
|
>
|
||||||
|
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||||
|
{props.kicker}
|
||||||
|
</p>
|
||||||
|
<h3 class="text-text mb-2 text-lg font-bold tracking-tight">
|
||||||
|
{props.title}
|
||||||
|
</h3>
|
||||||
|
<p class="text-subtext0 text-sm leading-relaxed">{props.body}</p>
|
||||||
|
<div class="mt-5 flex-1">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage for app-UI mocks: the island's black ink ground. The real island
|
||||||
|
* is always dark (preferredColorScheme .dark), so the mocks read correctly
|
||||||
|
* in both site themes.
|
||||||
|
*/
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fans panel mock: RPM gauge rows in the app's card chrome. */
|
||||||
|
function FansPanelMock() {
|
||||||
|
const fans = [
|
||||||
|
{ rpm: 1270, frac: 0.26 },
|
||||||
|
{ rpm: 1219, frac: 0.25 }
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ModuleCard>
|
||||||
|
<div class="space-y-2.5">
|
||||||
|
<For each={fans}>
|
||||||
|
{(f) => (
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="h-1 flex-1 overflow-hidden rounded-full"
|
||||||
|
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${f.frac * 100}%`,
|
||||||
|
background: CALM
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="font-mono text-white"
|
||||||
|
style={{ "font-size": "13px", "font-weight": 500 }}
|
||||||
|
>
|
||||||
|
{f.rpm}
|
||||||
|
</span>
|
||||||
|
<span class="text-[9px] font-semibold text-white/50">RPM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="rounded-md border border-white/15 px-2 py-0.5 text-[11px] text-white/70">
|
||||||
|
Auto
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="rounded-md px-2 py-0.5 text-[11px] font-bold"
|
||||||
|
style={{ color: CRITICAL }}
|
||||||
|
>
|
||||||
|
MAX
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 (
|
||||||
|
<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>
|
||||||
|
</ModuleCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeatureCollage() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
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">
|
||||||
|
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
|
||||||
|
Why the Nook
|
||||||
|
</p>
|
||||||
|
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||||
|
One glance. Everything your agents are doing.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<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="mb-5 self-start">
|
||||||
|
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||||
|
The island
|
||||||
|
</p>
|
||||||
|
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="my-10 flex w-full justify-center">
|
||||||
|
<IslandPill
|
||||||
|
campfire="running"
|
||||||
|
right={
|
||||||
|
<div class="flex flex-col items-center gap-1 px-1">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.running }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.ready }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.attention }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: STATUS.idle }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext0 -mb-1 text-center text-xs">
|
||||||
|
expand for the full panel ↓
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approvals */}
|
||||||
|
<CollageCell
|
||||||
|
kicker="Approvals"
|
||||||
|
title="Unblock agents, anywhere"
|
||||||
|
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
|
||||||
|
agent={CLAUDE}
|
||||||
|
title="the-nook · bridge"
|
||||||
|
tool="Edit"
|
||||||
|
input="server/routes.ts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
|
||||||
|
{/* Fans */}
|
||||||
|
<CollageCell
|
||||||
|
kicker="Cooling"
|
||||||
|
title="Silence it or max it"
|
||||||
|
body="Read both fans live, then take over the curve completely. Flip to MAX before a compile, whisper-quiet when the fleet idles."
|
||||||
|
>
|
||||||
|
<InkStage class="w-full">
|
||||||
|
<div class="w-full max-w-[260px] text-left">
|
||||||
|
<FansPanelMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
|
||||||
|
{/* Gauges */}
|
||||||
|
<CollageCell
|
||||||
|
kicker="Your Mac"
|
||||||
|
title="The gauges that matter"
|
||||||
|
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">
|
||||||
|
<GaugesMock />
|
||||||
|
</div>
|
||||||
|
</InkStage>
|
||||||
|
</CollageCell>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-subtext1 mt-6 text-center text-xs">
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
899
src/components/nook/IslandExtras.tsx
Normal file
899
src/components/nook/IslandExtras.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
295
src/components/nook/IslandMock.tsx
Normal file
295
src/components/nook/IslandMock.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { For, createMemo, onMount, onCleanup, type JSX } from "solid-js";
|
||||||
|
import { Campfire, type CampfireState } from "./Campfire";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faithful mock of the island's collapsed pill and expanded panel.
|
||||||
|
* Geometry follows the app's IslandSurfaceShape: the top edge spans the
|
||||||
|
* full width, the notch housing sits centered, and each corner is a
|
||||||
|
* concave scoop (quadratic curve with the control ON the top edge).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* The app's island indicator palette (AgentsModuleView.statusColor). */
|
||||||
|
export const STATUS = {
|
||||||
|
running: "#6EA7FF",
|
||||||
|
ready: "#6FB982",
|
||||||
|
attention: "#E7A762",
|
||||||
|
error: "#E5484D",
|
||||||
|
idle: "rgba(255,255,255,0.35)"
|
||||||
|
};
|
||||||
|
|
||||||
|
/** MetricPalette.calm — the desaturated sky of every metric bar. */
|
||||||
|
export const CALM = "#6EA7FF";
|
||||||
|
export const WARM = "#E7A762";
|
||||||
|
export const CRITICAL = "rgba(229,72,77,0.9)";
|
||||||
|
|
||||||
|
/* NookPalette.accent — brand + live interactive state, never status. */
|
||||||
|
export const ACCENT = "#4897b2";
|
||||||
|
|
||||||
|
/** The expanded panel's card chrome: white 5.5% fill, white 7% stroke. */
|
||||||
|
export const CARD_FILL = "rgba(255,255,255,0.055)";
|
||||||
|
export const CARD_STROKE = "rgba(255,255,255,0.07)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One module-card surface shared by every mock panel — the app's
|
||||||
|
* cardChrome(): rounded 12, hairline stroke, white-on-black fill.
|
||||||
|
*/
|
||||||
|
export function ModuleCard(props: {
|
||||||
|
children: JSX.Element;
|
||||||
|
class?: string;
|
||||||
|
style?: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`rounded-xl ${props.class ?? ""}`}
|
||||||
|
style={{
|
||||||
|
background: CARD_FILL,
|
||||||
|
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`,
|
||||||
|
...props.style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="p-3">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Island surface, faithful to the app's IslandSurfaceShape: the top edge
|
||||||
|
* spans the full outer width while the body is inset, each corner joining
|
||||||
|
* them with a concave quad flare (control point ON the top edge), the
|
||||||
|
* bottom a convex 14px round. Rendered as an inline SVG with
|
||||||
|
* foreignObject-clip via CSS `clip-path: path(...)`.
|
||||||
|
*/
|
||||||
|
const FLARE = 22; // the app's topRadius — how far the wings taper inward
|
||||||
|
const BOTTOM_R = 14;
|
||||||
|
|
||||||
|
function islandPath(w: number, h: number): string {
|
||||||
|
const tr = Math.min(FLARE, w / 2);
|
||||||
|
const br = Math.min(BOTTOM_R, tr);
|
||||||
|
// Mirrors IslandSurfaceShape.path exactly (same node order, same controls).
|
||||||
|
return (
|
||||||
|
`M0,0 L${w},0 ` +
|
||||||
|
`Q${w - tr},0 ${w - tr},${tr} ` +
|
||||||
|
`L${w - tr},${h - br} ` +
|
||||||
|
`Q${w - tr},${h} ${w - tr - br},${h} ` +
|
||||||
|
`L${tr + br},${h} ` +
|
||||||
|
`Q${tr},${h} ${tr},${h - br} ` +
|
||||||
|
`L${tr},${tr} ` +
|
||||||
|
`Q${tr},0 0,0 Z`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IslandSurface(props: { children: JSX.Element; class?: string }) {
|
||||||
|
let el: HTMLDivElement | undefined;
|
||||||
|
// The path must track the element's real size; a fixed viewBox would
|
||||||
|
// stretch the flare. Measure on mount + resize.
|
||||||
|
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 = () => {
|
||||||
|
node.style.clipPath = `path("${islandPath(node.offsetWidth, node.offsetHeight)}")`;
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
const observer = new ResizeObserver(apply);
|
||||||
|
observer.observe(node);
|
||||||
|
onCleanup(() => observer.disconnect());
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={el}
|
||||||
|
class={`bg-black ${props.class ?? ""}`}
|
||||||
|
style={{ "border-radius": "0 0 14px 14px" }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISLAND … */
|
||||||
|
const NOTCH_NAME = "The Nook";
|
||||||
|
|
||||||
|
/* ── Collapsed pill ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collapsed pill: campfire slot left, camera housing center, live
|
||||||
|
* data slot right. Slot squares are native (no menu-bar stub — the wings
|
||||||
|
* hang from nothing, like a floating notch).
|
||||||
|
*/
|
||||||
|
export function IslandPill(props: {
|
||||||
|
campfire?: CampfireState;
|
||||||
|
right?: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<IslandSurface class="mx-auto w-fit">
|
||||||
|
<div class="flex items-end" style={{ height: "60px" }}>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
style={{ width: "86px", height: "56px" }}
|
||||||
|
>
|
||||||
|
<Campfire state={props.campfire ?? "running"} pixel={4.2} />
|
||||||
|
</div>
|
||||||
|
{/* camera housing: lens dot centered like the real notch */}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
style={{ width: "150px", height: "56px" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="rounded-full"
|
||||||
|
style={{
|
||||||
|
width: "12px",
|
||||||
|
height: "12px",
|
||||||
|
background: "radial-gradient(circle at 40% 35%, #2a3a4a 0%, #0a0c10 70%)",
|
||||||
|
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.06)"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center"
|
||||||
|
style={{ width: "86px", height: "56px" }}
|
||||||
|
>
|
||||||
|
{props.right}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</IslandSurface>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agents dot-grid slot indicator: balanced rows of status squares. */
|
||||||
|
export function AgentDotGrid(props: { states: (keyof typeof STATUS)[] }) {
|
||||||
|
// Balanced rows: 3+3+2 for 8, 2x2 for 4, 3 for 3 … app: 1,2,3,4=2x2,
|
||||||
|
// 5=[3,2], 6=[3,3], 7=[4,3], 8=[4,4], 9=[3,3,3]
|
||||||
|
const rows = (n: number): number[] => {
|
||||||
|
switch (n) {
|
||||||
|
case 1:
|
||||||
|
return [1];
|
||||||
|
case 2:
|
||||||
|
return [2];
|
||||||
|
case 3:
|
||||||
|
return [3];
|
||||||
|
case 4:
|
||||||
|
return [2, 2];
|
||||||
|
case 5:
|
||||||
|
return [3, 2];
|
||||||
|
case 6:
|
||||||
|
return [3, 3];
|
||||||
|
case 7:
|
||||||
|
return [4, 3];
|
||||||
|
case 8:
|
||||||
|
return [4, 4];
|
||||||
|
default:
|
||||||
|
return [4, 4];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sizes = rows(props.states.length);
|
||||||
|
const groups = createMemo(() => {
|
||||||
|
let cursor = 0;
|
||||||
|
return sizes.map((count) => props.states.slice(cursor, (cursor += count)));
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center gap-[1.5px]">
|
||||||
|
<For each={groups()}>
|
||||||
|
{(group) => (
|
||||||
|
<div class="flex gap-[1.5px]">
|
||||||
|
<For each={group}>
|
||||||
|
{(s) => (
|
||||||
|
<span
|
||||||
|
class="rounded-[1.5px]"
|
||||||
|
style={{
|
||||||
|
width: "7px",
|
||||||
|
height: "7px",
|
||||||
|
background: STATUS[s]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Expanded panel ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded island: header ("The Nook" + "N active" + controls),
|
||||||
|
* tab strip, divider, then one panel page. Width ~ the app's expanded
|
||||||
|
* footprint on a 14" display.
|
||||||
|
*/
|
||||||
|
export function IslandExpanded(props: {
|
||||||
|
activeCount: number;
|
||||||
|
tabs: { icon: string; label: string; active?: boolean }[];
|
||||||
|
children: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="w-[560px] max-w-full">
|
||||||
|
<IslandSurface>
|
||||||
|
{/* header */}
|
||||||
|
<div class="flex items-center px-[18px] py-2.5">
|
||||||
|
<span class="text-[14px] font-semibold text-white">{NOTCH_NAME}</span>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<span class="text-[11px] text-white/50">
|
||||||
|
{props.activeCount} active
|
||||||
|
</span>
|
||||||
|
<span class="ml-3 text-[11px] font-semibold text-white/60">⚙</span>
|
||||||
|
<span class="ml-3 text-[11px] font-semibold text-white/60">✕</span>
|
||||||
|
</div>
|
||||||
|
{/* tab strip: one icon button per panel; active wears accent 22% */}
|
||||||
|
<div class="flex gap-2 px-[18px] pb-1.5">
|
||||||
|
<For each={props.tabs}>
|
||||||
|
{(tab) => (
|
||||||
|
<span
|
||||||
|
title={tab.label}
|
||||||
|
class="flex items-center justify-center rounded-[5px]"
|
||||||
|
style={{
|
||||||
|
width: "22px",
|
||||||
|
height: "22px",
|
||||||
|
"font-size": "13px",
|
||||||
|
background: tab.active ? `${ACCENT}38` : "transparent",
|
||||||
|
color: tab.active ? "rgba(255,255,255,0.95)" : "rgba(255,255,255,0.5)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class="h-px bg-white/10" />
|
||||||
|
<div class="px-[18px] py-4">{props.children}</div>
|
||||||
|
</IslandSurface>
|
||||||
|
</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"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
405
src/components/nook/ModuleMocks.tsx
Normal file
405
src/components/nook/ModuleMocks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
617
src/components/nook/MoreDemos.tsx
Normal file
617
src/components/nook/MoreDemos.tsx
Normal 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">
|
||||||
|
>{" "}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
src/components/nook/PhoneBeta.tsx
Normal file
286
src/components/nook/PhoneBeta.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/nook/betaRequest.ts
Normal file
12
src/components/nook/betaRequest.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
28
src/components/nook/useDemoLoop.ts
Normal file
28
src/components/nook/useDemoLoop.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
* prefix-strip below also handles any legacy prefixed form, so the canonical
|
* prefix-strip below also handles any legacy prefixed form, so the canonical
|
||||||
* is always the public URL.
|
* is always the public URL.
|
||||||
* - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`.
|
* - `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
|
* - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall
|
||||||
* back to the base title (no suffix) / description (existing behavior).
|
* back to the base title (no suffix) / description (existing behavior).
|
||||||
*/
|
*/
|
||||||
@@ -49,6 +52,20 @@ export interface ResolvedPageHeadMeta {
|
|||||||
ogImage: string;
|
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(
|
export function resolvePageHeadMeta(
|
||||||
props: PageHeadProps,
|
props: PageHeadProps,
|
||||||
site: Site,
|
site: Site,
|
||||||
@@ -71,7 +88,10 @@ export function resolvePageHeadMeta(
|
|||||||
const canonical = props.canonical ?? `https://${site.domain}${publicPath}`;
|
const canonical = props.canonical ?? `https://${site.domain}${publicPath}`;
|
||||||
const ogTitle = props.ogTitle ?? props.title;
|
const ogTitle = props.ogTitle ?? props.title;
|
||||||
const ogDescription = props.ogDescription ?? props.description;
|
const ogDescription = props.ogDescription ?? props.description;
|
||||||
const ogImage = props.ogImage ?? site.ogDefaultImage;
|
const ogImage = absolutizeOgImage(
|
||||||
|
props.ogImage ?? site.ogDefaultImage,
|
||||||
|
site.domain
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ export interface VisitorAnalytics {
|
|||||||
device_type?: string | null;
|
device_type?: string | null;
|
||||||
browser?: string | null;
|
browser?: string | null;
|
||||||
os?: string | null;
|
os?: string | null;
|
||||||
|
utm_source?: string | null;
|
||||||
|
utm_medium?: string | null;
|
||||||
|
utm_campaign?: string | null;
|
||||||
duration_ms?: number | null;
|
duration_ms?: number | null;
|
||||||
fcp?: number | null;
|
fcp?: number | null;
|
||||||
lcp?: number | null;
|
lcp?: number | null;
|
||||||
@@ -157,6 +160,8 @@ export interface VisitorAnalytics {
|
|||||||
ttfb?: number | null;
|
ttfb?: number | null;
|
||||||
dom_load?: number | null;
|
dom_load?: number | null;
|
||||||
load_complete?: number | null;
|
load_complete?: number | null;
|
||||||
|
stage?: string | null;
|
||||||
|
host?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ function handleChunkError(source: string): void {
|
|||||||
window.addEventListener("error", (event) => {
|
window.addEventListener("error", (event) => {
|
||||||
if (
|
if (
|
||||||
event.message?.includes("Importing a module script failed") ||
|
event.message?.includes("Importing a module script failed") ||
|
||||||
event.message?.includes("Failed to fetch dynamically imported module")
|
event.message?.includes("Failed to fetch dynamically imported module") ||
|
||||||
|
event.message?.includes("error loading dynamically imported module")
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleChunkError("error event");
|
handleChunkError("error event");
|
||||||
@@ -111,9 +112,8 @@ window.addEventListener("error", (event) => {
|
|||||||
window.addEventListener("unhandledrejection", (event) => {
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
if (
|
if (
|
||||||
event.reason?.message?.includes("Importing a module script failed") ||
|
event.reason?.message?.includes("Importing a module script failed") ||
|
||||||
event.reason?.message?.includes(
|
event.reason?.message?.includes("Failed to fetch dynamically imported module") ||
|
||||||
"Failed to fetch dynamically imported module"
|
event.reason?.message?.includes("error loading dynamically imported module")
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleChunkError("unhandled rejection");
|
handleChunkError("unhandled rejection");
|
||||||
|
|||||||
31
src/env/server.ts
vendored
31
src/env/server.ts
vendored
@@ -53,18 +53,7 @@ const serverEnvSchema = z.object({
|
|||||||
VITE_WEBSOCKET: z.string().min(1),
|
VITE_WEBSOCKET: z.string().min(1),
|
||||||
VITE_INFILL_ENDPOINT: z.string().min(1),
|
VITE_INFILL_ENDPOINT: z.string().min(1),
|
||||||
INFILL_BEARER_TOKEN: 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),
|
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(),
|
APPLE_CLIENT_ID_LINEAGE: z.string().min(1).optional(),
|
||||||
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
|
VITE_TURNSTILE_SITE_KEY: z.string().min(1),
|
||||||
TURNSTILE_SECRET_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_LICENSE_PRIVATE_KEY: z.string().min(1),
|
||||||
NOOK_STRIPE_SK: z.string().min(1),
|
NOOK_STRIPE_SK: z.string().min(1),
|
||||||
NOOK_STRIPE_WEBHOOK_SECRET: 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>;
|
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||||
@@ -176,11 +178,6 @@ export const getMissingEnvVars = (): string[] => {
|
|||||||
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
|
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
|
||||||
"VITE_GITHUB_CLIENT_ID",
|
"VITE_GITHUB_CLIENT_ID",
|
||||||
"VITE_WEBSOCKET",
|
"VITE_WEBSOCKET",
|
||||||
"NESSA_DB_URL",
|
|
||||||
"NESSA_DB_TOKEN",
|
|
||||||
"NESSA_CLERK_SECRET",
|
|
||||||
"NESSA_CLERK_JWT_ISSUER",
|
|
||||||
"NESSA_CLERK_WEBHOOK_SECRET",
|
|
||||||
"LINEAGE_JWT_SECRET"
|
"LINEAGE_JWT_SECRET"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,35 @@
|
|||||||
* Tests inject a fake `api` and a capture sink.
|
* 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. */
|
/** Structural shape of the tRPC downloads proxy this helper depends on. */
|
||||||
export interface DownloadApi {
|
export interface DownloadApi {
|
||||||
downloads: {
|
downloads: {
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ import { SITE_CONFIG, type SiteId } from "./site-context";
|
|||||||
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
const ALL_SITES: SiteId[] = ["main", "nessa", "lineage", "gaze", "inputhalo", "nook"];
|
||||||
|
|
||||||
describe("NAV_CONFIG — per-site link sets", () => {
|
describe("NAV_CONFIG — per-site link sets", () => {
|
||||||
it("main → Home, Blog, Downloads, Resume, Contact, GitHub, LinkedIn", () => {
|
it("main → Home, Blog, Downloads, Resume, Contact, Privacy, GitHub, LinkedIn", () => {
|
||||||
expect(navLabelsFor("main")).toEqual([
|
expect(navLabelsFor("main")).toEqual([
|
||||||
"Home",
|
"Home",
|
||||||
"Blog",
|
"Blog",
|
||||||
"Downloads",
|
"Downloads",
|
||||||
"Resume",
|
"Resume",
|
||||||
"Contact",
|
"Contact",
|
||||||
|
"Privacy",
|
||||||
"GitHub",
|
"GitHub",
|
||||||
"LinkedIn"
|
"LinkedIn"
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
|||||||
{ label: "Downloads", href: "/downloads", icon: "downloads" },
|
{ label: "Downloads", href: "/downloads", icon: "downloads" },
|
||||||
{ label: "Resume", href: "/resume", icon: "resume" },
|
{ label: "Resume", href: "/resume", icon: "resume" },
|
||||||
{ label: "Contact", href: "/contact", icon: "contact" },
|
{ label: "Contact", href: "/contact", icon: "contact" },
|
||||||
|
{ label: "Privacy", href: "/privacy-policy", icon: "privacy" },
|
||||||
{
|
{
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
href: "https://github.com/MikeFreno/",
|
href: "https://github.com/MikeFreno/",
|
||||||
@@ -119,6 +120,9 @@ export const NAV_CONFIG: Record<SiteId, NavItem[]> = {
|
|||||||
],
|
],
|
||||||
nook: [
|
nook: [
|
||||||
{ label: "Home", href: "/", icon: "home" },
|
{ 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" }
|
{ label: "Privacy", href: "/privacy", icon: "privacy" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export const SITE_CONFIG: Record<SiteId, Site> = {
|
|||||||
titleSuffix: " | The Nook",
|
titleSuffix: " | The Nook",
|
||||||
brandColor: "#4C9FBC",
|
brandColor: "#4C9FBC",
|
||||||
brandColorDark: "#4C9FBC",
|
brandColorDark: "#4C9FBC",
|
||||||
ogDefaultImage: "/nook/og-default.png",
|
ogDefaultImage: "/nook/og.png",
|
||||||
faviconPath: "/nook/favicon/favicon.ico"
|
faviconPath: "/nook/favicon/favicon.ico"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,14 +40,15 @@ describe("generateSitemap", () => {
|
|||||||
expect(locs).toContain("https://freno.me/login");
|
expect(locs).toContain("https://freno.me/login");
|
||||||
expect(locs).toContain("https://freno.me/resume");
|
expect(locs).toContain("https://freno.me/resume");
|
||||||
expect(locs).toContain("https://freno.me/downloads");
|
expect(locs).toContain("https://freno.me/downloads");
|
||||||
|
expect(locs).toContain("https://freno.me/privacy-policy");
|
||||||
|
|
||||||
expect(locs.length).toBe(6);
|
expect(locs.length).toBe(7);
|
||||||
|
|
||||||
expect(xml).toContain("</urlset>");
|
expect(xml).toContain("</urlset>");
|
||||||
const urlOpens = (xml.match(/<url>/g) || []).length;
|
const urlOpens = (xml.match(/<url>/g) || []).length;
|
||||||
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
const urlCloses = (xml.match(/<\/url>/g) || []).length;
|
||||||
expect(urlOpens).toBe(urlCloses);
|
expect(urlOpens).toBe(urlCloses);
|
||||||
expect(urlOpens).toBe(6);
|
expect(urlOpens).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates valid parseable XML for lineage site", () => {
|
it("generates valid parseable XML for lineage site", () => {
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
|||||||
{ path: "/contact", changefreq: "monthly", priority: 0.7 },
|
{ path: "/contact", changefreq: "monthly", priority: 0.7 },
|
||||||
{ path: "/login", changefreq: "monthly", priority: 0.5 },
|
{ path: "/login", changefreq: "monthly", priority: 0.5 },
|
||||||
{ path: "/resume", changefreq: "yearly", priority: 0.6 },
|
{ path: "/resume", changefreq: "yearly", priority: 0.6 },
|
||||||
{ path: "/downloads", changefreq: "weekly", priority: 0.8 }
|
{ path: "/downloads", changefreq: "weekly", priority: 0.8 },
|
||||||
|
{ path: "/privacy-policy", changefreq: "yearly", priority: 0.4 }
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── Subdomain sites ──────────────────────────────────────────────────
|
// ── Subdomain sites ──────────────────────────────────────────────────
|
||||||
@@ -82,6 +83,9 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
|||||||
nook: [
|
nook: [
|
||||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||||
{ path: "/checkout", changefreq: "monthly", priority: 0.5 },
|
{ 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 }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { HttpStatusCode } from "@solidjs/start";
|
import { HttpHeader, HttpStatusCode } from "@solidjs/start";
|
||||||
import { useLocation, useNavigate } from "@solidjs/router";
|
import { useLocation, useNavigate } from "@solidjs/router";
|
||||||
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
import { createSignal, onCleanup, onMount, Show } from "solid-js";
|
||||||
import { TerminalErrorPage } from "~/components/TerminalErrorPage";
|
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."
|
description="404 - Page not found. The page you're looking for doesn't exist."
|
||||||
/>
|
/>
|
||||||
<HttpStatusCode code={404} />
|
<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
|
<TerminalErrorPage
|
||||||
errorContent={errorContent}
|
errorContent={errorContent}
|
||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
|
|||||||
24
src/routes/agents.tsx
Normal file
24
src/routes/agents.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -202,6 +202,32 @@ function FrenomePanel(props: FrenomePanelProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Performance */}
|
||||||
<Show when={performanceStats()} keyed>
|
<Show when={performanceStats()} keyed>
|
||||||
{(ps) => (
|
{(ps) => (
|
||||||
|
|||||||
35
src/routes/api/analytics-retention.ts
Normal file
35
src/routes/api/analytics-retention.ts
Normal 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" }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
|
import { trackDownload } from "~/server/track-download";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serves macOS app DMG files and delta updates from S3
|
* Serves macOS app DMG files and delta updates from S3
|
||||||
@@ -8,7 +9,10 @@ import { env } from "~/env/server";
|
|||||||
*
|
*
|
||||||
* Handles:
|
* Handles:
|
||||||
* - Full DMG files: /api/downloads/Gaze-0.2.2.dmg, /api/downloads/InputHalo-0.1.0.dmg
|
* - 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]
|
* URL: https://freno.me/api/downloads/[filename]
|
||||||
*/
|
*/
|
||||||
@@ -24,10 +28,20 @@ export async function GET(event: APIEvent) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPrefixes = ["Gaze", "InputHalo", "TheNook"];
|
// Prefix → canonical asset key, so a version bump stays one asset in
|
||||||
const isValidPrefix = validPrefixes.some((prefix) => filename.startsWith(prefix));
|
// 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 (
|
if (
|
||||||
!isValidPrefix ||
|
!asset ||
|
||||||
(!filename.endsWith(".dmg") &&
|
(!filename.endsWith(".dmg") &&
|
||||||
!filename.endsWith(".delta") &&
|
!filename.endsWith(".delta") &&
|
||||||
!filename.endsWith(".zip"))
|
!filename.endsWith(".zip"))
|
||||||
@@ -81,13 +95,20 @@ export async function GET(event: APIEvent) {
|
|||||||
|
|
||||||
console.log(`✓ Serving ${filename} (${body.length} bytes)`);
|
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,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Content-Length": body.length.toString(),
|
"Content-Length": body.length.toString(),
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
|
"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": "*"
|
"Access-Control-Allow-Origin": "*"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
48
src/routes/api/downloads/filename.test.ts
Normal file
48
src/routes/api/downloads/filename.test.ts
Normal 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
23
src/routes/api/health.ts
Normal 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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
8
src/routes/api/lineage/pvp/purchase.ts
Normal file
8
src/routes/api/lineage/pvp/purchase.ts
Normal 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);
|
||||||
114
src/routes/api/the-nook/_appcast.ts
Normal file
114
src/routes/api/the-nook/_appcast.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { env } from "~/env/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appcast reading for The Nook, shared by the download resolver and the
|
||||||
|
* /changelog page. The same route-module tree-shake rule applies as in
|
||||||
|
* `_download.ts`: helpers live in an underscore-prefixed module because
|
||||||
|
* route files may only export HTTP handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const APPCAST_KEY = "api/TheNook/appcast.xml";
|
||||||
|
|
||||||
|
/** Kept in step with the appcast route's own 5-minute cache. */
|
||||||
|
const TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface AppcastRelease {
|
||||||
|
/** Human version, e.g. "1.2.0" — from sparkle:shortVersionString. */
|
||||||
|
version: string;
|
||||||
|
/** Build number from sparkle:version, when present. */
|
||||||
|
build: string | null;
|
||||||
|
/** Release date as published (already human-formatted in the appcast). */
|
||||||
|
date: string | null;
|
||||||
|
/** HTML release notes exactly as the Sparkle updater receives them. */
|
||||||
|
notesHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extract(tag: string, xml: string): string | null {
|
||||||
|
const match = xml.match(
|
||||||
|
new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, "i")
|
||||||
|
);
|
||||||
|
return match?.[1]?.trim() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCdata(value: string): string {
|
||||||
|
const cdata = value.match(/^<!\[CDATA\[([\s\S]*)\]\]>$/);
|
||||||
|
const inner = (cdata ? cdata[1] : value).trim();
|
||||||
|
if (cdata) return inner;
|
||||||
|
// Non-CDATA text is XML-escaped; descriptions arrive as escaped HTML.
|
||||||
|
return inner
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips the conventional "Version " prefix from an appcast item title. */
|
||||||
|
function versionFromTitle(title: string): string {
|
||||||
|
return title.replace(/^version\s+/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a Sparkle appcast into release entries, newest first (the appcast's
|
||||||
|
* own order). Empty or unparseable input yields an empty list — the page
|
||||||
|
* renders its empty state rather than an error.
|
||||||
|
*/
|
||||||
|
export function parseAppcastItems(xml: string): AppcastRelease[] {
|
||||||
|
const releases: AppcastRelease[] = [];
|
||||||
|
for (const match of xml.matchAll(/<item\b[^>]*>([\s\S]*?)<\/item>/gi)) {
|
||||||
|
const item = match[1];
|
||||||
|
if (!item) continue;
|
||||||
|
const short = decodeCdata(extract("sparkle:shortVersionString", item) ?? "");
|
||||||
|
const title = versionFromTitle(decodeCdata(extract("title", item) ?? ""));
|
||||||
|
const version = short || title;
|
||||||
|
if (!version) continue;
|
||||||
|
releases.push({
|
||||||
|
version,
|
||||||
|
build: decodeCdata(extract("sparkle:version", item) ?? "") || null,
|
||||||
|
date: decodeCdata(extract("pubDate", item) ?? "") || null,
|
||||||
|
notesHtml: decodeCdata(extract("description", item) ?? "")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return releases;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: { xml: string; at: number } | null = null;
|
||||||
|
|
||||||
|
/** Drops the cached appcast so the next read hits S3 again. */
|
||||||
|
export function invalidateAppcastCache(): void {
|
||||||
|
cached = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads (and briefly caches) the raw appcast XML from the download bucket. */
|
||||||
|
export async function fetchAppcastXml(): Promise<string | null> {
|
||||||
|
if (cached && Date.now() - cached.at < TTL_MS) {
|
||||||
|
return cached.xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
region: env.AWS_REGION,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.MY_AWS_ACCESS_KEY,
|
||||||
|
secretAccessKey: env.MY_AWS_SECRET_KEY
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: env.VITE_DOWNLOAD_BUCKET_STRING,
|
||||||
|
Key: APPCAST_KEY
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!response.Body) return null;
|
||||||
|
|
||||||
|
const xml = await response.Body.transformToString();
|
||||||
|
cached = { xml, at: Date.now() };
|
||||||
|
return xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The releases behind /changelog, or an empty list when the appcast is unreadable. */
|
||||||
|
export async function fetchAppcastReleases(): Promise<AppcastRelease[]> {
|
||||||
|
const xml = await fetchAppcastXml();
|
||||||
|
return xml ? parseAppcastItems(xml) : [];
|
||||||
|
}
|
||||||
75
src/routes/api/the-nook/_download.ts
Normal file
75
src/routes/api/the-nook/_download.ts
Normal 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;
|
||||||
|
}
|
||||||
185
src/routes/api/the-nook/_releases.ts
Normal file
185
src/routes/api/the-nook/_releases.ts
Normal 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][] = [
|
||||||
|
[/</g, "<"],
|
||||||
|
[/>/g, ">"],
|
||||||
|
[/"/g, '"'],
|
||||||
|
// Python's html.escape, which embed_release_notes.py writes the appcast
|
||||||
|
// with, prefers the hex form for apostrophes (NOOK-565).
|
||||||
|
[/'/g, "'"],
|
||||||
|
[/'/g, "'"],
|
||||||
|
[/'/g, "'"],
|
||||||
|
[/ /g, " "],
|
||||||
|
// Last, so "&lt;" cannot decode twice.
|
||||||
|
[/&/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);
|
||||||
|
}
|
||||||
64
src/routes/api/the-nook/appcast.test.ts
Normal file
64
src/routes/api/the-nook/appcast.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { parseAppcastItems } from "./_appcast";
|
||||||
|
|
||||||
|
// The /changelog page renders exactly what the Sparkle updater receives, so
|
||||||
|
// the parser's contract is the appcast's contract: newest-first items, a
|
||||||
|
// shortVersionString title with the raw <title> as fallback, CDATA and
|
||||||
|
// plain-encoded descriptions both accepted, garbage in → empty list out.
|
||||||
|
|
||||||
|
const item = (over: Record<string, string>) => `
|
||||||
|
<item>
|
||||||
|
<title>Version ${over.title ?? "1.0.0"}</title>
|
||||||
|
${over.short ? `<sparkle:shortVersionString>${over.short}</sparkle:shortVersionString>` : ""}
|
||||||
|
${over.build ? `<sparkle:version>${over.build}</sparkle:version>` : ""}
|
||||||
|
${over.date ? `<pubDate>${over.date}</pubDate>` : ""}
|
||||||
|
${over.desc ?? `<description><![CDATA[<ul><li>Fixes</li></ul>]]></description>`}
|
||||||
|
<enclosure url="https://example.com/Nook-1.0.0.dmg" type="application/x-apple-diskimage"/>
|
||||||
|
</item>`;
|
||||||
|
|
||||||
|
describe("parseAppcastItems", () => {
|
||||||
|
it("parses newest-first items with version, build, date and CDATA notes", () => {
|
||||||
|
const xml = `<rss><channel>
|
||||||
|
${item({ title: "1.2.0", short: "1.2.0", build: "42", date: "Sat, 03 Oct 2026 12:00:00 +0000" })}
|
||||||
|
${item({ title: "1.1.0", short: "1.1.0", build: "41", date: "Fri, 02 Oct 2026 12:00:00 +0000" })}
|
||||||
|
</channel></rss>`;
|
||||||
|
|
||||||
|
const releases = parseAppcastItems(xml);
|
||||||
|
expect(releases.length).toBe(2);
|
||||||
|
expect(releases[0].version).toBe("1.2.0");
|
||||||
|
expect(releases[0].build).toBe("42");
|
||||||
|
expect(releases[0].date).toContain("03 Oct 2026");
|
||||||
|
expect(releases[0].notesHtml).toBe("<ul><li>Fixes</li></ul>");
|
||||||
|
expect(releases[1].version).toBe("1.1.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the plain title when shortVersionString is absent", () => {
|
||||||
|
const xml = `<rss><channel>${item({ title: "1.0.0" })}</channel></rss>`;
|
||||||
|
const releases = parseAppcastItems(xml);
|
||||||
|
expect(releases[0].version).toBe("1.0.0");
|
||||||
|
expect(releases[0].build).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts non-CDATA escaped-HTML descriptions", () => {
|
||||||
|
const xml = `<rss><channel>
|
||||||
|
${item({ desc: "<description><p>Hello</p></description>" })}
|
||||||
|
</channel></rss>`;
|
||||||
|
expect(parseAppcastItems(xml)[0].notesHtml).toBe("<p>Hello</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for empty, non-XML, or item-less input", () => {
|
||||||
|
expect(parseAppcastItems("")).toEqual([]);
|
||||||
|
expect(parseAppcastItems("<html>not an appcast</html>")).toEqual([]);
|
||||||
|
expect(parseAppcastItems("<rss><channel></channel></rss>")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips items with no version signal at all", () => {
|
||||||
|
const xml = `<rss><channel>
|
||||||
|
<item><description><![CDATA[<p>orphan</p>]]></description></item>
|
||||||
|
${item({ title: "1.0.0" })}
|
||||||
|
</channel></rss>`;
|
||||||
|
const releases = parseAppcastItems(xml);
|
||||||
|
expect(releases.length).toBe(1);
|
||||||
|
expect(releases[0].version).toBe("1.0.0");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { env } from "~/env/server";
|
|||||||
import { TURNSTILE_CONFIG } from "~/config";
|
import { TURNSTILE_CONFIG } from "~/config";
|
||||||
import { verifyTurnstileToken } from "~/server/fetch-utils";
|
import { verifyTurnstileToken } from "~/server/fetch-utils";
|
||||||
import { json, error } from "./_lib";
|
import { json, error } from "./_lib";
|
||||||
|
import { trackRequest } from "~/server/track-download";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/the-nook/checkout
|
* 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
|
* session for the one-time $10 The Nook license. Returns the hosted
|
||||||
* checkout URL for the client to redirect to.
|
* 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
|
* Uses raw `fetch` (Node 24 / Vercel Node functions have global fetch) — no
|
||||||
* `stripe` npm dependency added.
|
* `stripe` npm dependency added.
|
||||||
*/
|
*/
|
||||||
@@ -67,5 +76,14 @@ export async function POST(event: APIEvent) {
|
|||||||
if (!data.url) {
|
if (!data.url) {
|
||||||
return error("Stripe returned no checkout URL", 502);
|
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 });
|
return json({ checkoutUrl: data.url });
|
||||||
}
|
}
|
||||||
|
|||||||
218
src/routes/api/the-nook/download.test.ts
Normal file
218
src/routes/api/the-nook/download.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/routes/api/the-nook/download.ts
Normal file
55
src/routes/api/the-nook/download.ts
Normal 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" }
|
||||||
|
});
|
||||||
|
}
|
||||||
230
src/routes/api/the-nook/lifecycle.test.ts
Normal file
230
src/routes/api/the-nook/lifecycle.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
136
src/routes/api/the-nook/lifecycle.ts
Normal file
136
src/routes/api/the-nook/lifecycle.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/routes/api/the-nook/releases.json.ts
Normal file
35
src/routes/api/the-nook/releases.json.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/routes/api/the-nook/releases.test.ts
Normal file
298
src/routes/api/the-nook/releases.test.ts
Normal 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; "End Call" no longer matches "Send".</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's meter & the icon</li><li>don'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));
|
||||||
199
src/routes/api/the-nook/report.test.ts
Normal file
199
src/routes/api/the-nook/report.test.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// The route's core contracts:
|
||||||
|
// 1. A valid report is emailed to the owner with the machine snapshot
|
||||||
|
// 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) => {
|
||||||
|
sentEmails.push({ to, subject, html });
|
||||||
|
return { success: true, messageId: "test" };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("~/server/bug-report-rate-limit", () => ({
|
||||||
|
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> = {}) {
|
||||||
|
return {
|
||||||
|
request: {
|
||||||
|
json: async () => body,
|
||||||
|
headers: new Headers(headers)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPayload = {
|
||||||
|
appVersion: "The Nook 0.2.0 (12)",
|
||||||
|
title: "Island flickers",
|
||||||
|
description: "It happened <twice> & stayed",
|
||||||
|
contact: "",
|
||||||
|
machine: {
|
||||||
|
macOS: "macOS 15.5",
|
||||||
|
model: "Mac15,6",
|
||||||
|
cpu: "Apple M3 Pro",
|
||||||
|
memoryGB: 36,
|
||||||
|
freeDiskGB: 100,
|
||||||
|
locale: "en_US"
|
||||||
|
},
|
||||||
|
displays: [
|
||||||
|
{
|
||||||
|
name: "Built-in",
|
||||||
|
boundsPx: "3456×2234 pt",
|
||||||
|
scale: "2.0x",
|
||||||
|
hz: "120",
|
||||||
|
builtin: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DELL U2723QE",
|
||||||
|
boundsPx: "3008×1692 pt",
|
||||||
|
scale: "1.0x",
|
||||||
|
hz: "120",
|
||||||
|
builtin: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const res = await POST(request(validPayload));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(sentEmails).toHaveLength(1);
|
||||||
|
const email = sentEmails[0]!;
|
||||||
|
expect(email.subject).toBe("The Nook bug report: Island flickers");
|
||||||
|
expect(email.to).toBe("michael@freno.me");
|
||||||
|
for (const fact of ["Mac15,6", "Apple M3 Pro", "macOS 15.5", "36"]) {
|
||||||
|
expect(email.html).toContain(fact);
|
||||||
|
}
|
||||||
|
expect(email.html).toContain("Built-in");
|
||||||
|
expect(email.html).toContain("3456×2234 pt");
|
||||||
|
expect(email.html).toContain("DELL U2723QE");
|
||||||
|
expect(email.html).toContain("3008×1692 pt");
|
||||||
|
expect(email.html).toContain("<b>builtin:</b> false");
|
||||||
|
expect(email.html).toContain("It happened <twice> & stayed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing, empty, or oversized title", async () => {
|
||||||
|
const empty = await POST(request({ ...validPayload, title: " " }));
|
||||||
|
expect(empty.status).toBe(400);
|
||||||
|
const missing = await POST(
|
||||||
|
request({ ...validPayload, title: undefined })
|
||||||
|
);
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
const tooLong = await POST(
|
||||||
|
request({ ...validPayload, title: "x".repeat(201) })
|
||||||
|
);
|
||||||
|
expect(tooLong.status).toBe(400);
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed reply-to contact", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
request({ ...validPayload, contact: "not-an-email" })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects more than eight displays", async () => {
|
||||||
|
const displays = Array.from({ length: 9 }, () => validPayload.displays[0]!);
|
||||||
|
const res = await POST(request({ ...validPayload, displays }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 429 with Retry-After when the limiter denies the token", async () => {
|
||||||
|
tokenAnswers.push({ allowed: false, retryAfterSec: 3600 });
|
||||||
|
const res = await POST(request(validPayload));
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.headers.get("Retry-After")).toBe("3600");
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still succeeds when no fingerprint header exists", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
131
src/routes/api/the-nook/report.ts
Normal file
131
src/routes/api/the-nook/report.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/report
|
||||||
|
* Body: { appVersion, title, description, contact?, machine, displays }
|
||||||
|
*
|
||||||
|
* Emails the developer a Nook bug report in plain prose — each field on
|
||||||
|
* its own line. The Mac client collects the machine facts itself (model,
|
||||||
|
* CPU, memory, every display); styling is irrelevant, information is the
|
||||||
|
* product here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const displaySchema = z.object({
|
||||||
|
name: z.string().max(200).default(""),
|
||||||
|
boundsPx: z.string().max(100).default(""),
|
||||||
|
scale: z.string().max(20).default(""),
|
||||||
|
hz: z.string().max(20).default(""),
|
||||||
|
builtin: z.boolean().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
const reportSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
description: z.string().trim().max(20_000).default(""),
|
||||||
|
contact: z.string().trim().max(254).default(""),
|
||||||
|
appVersion: z.string().trim().max(100).default(""),
|
||||||
|
machine: z
|
||||||
|
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
||||||
|
.default({}),
|
||||||
|
displays: z.array(displaySchema).max(8).default([])
|
||||||
|
});
|
||||||
|
|
||||||
|
type Report = z.infer<typeof reportSchema>;
|
||||||
|
|
||||||
|
const contactRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
// One escaping contract for every interpolated string (many call sites).
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(report: Report): string {
|
||||||
|
const machineLines = Object.entries(report.machine).map(
|
||||||
|
([key, value]) => `<b>${escapeHtml(key)}:</b> ${escapeHtml(String(value))}`
|
||||||
|
);
|
||||||
|
const displayLines = report.displays.map((display) =>
|
||||||
|
[
|
||||||
|
`<b>name:</b> ${escapeHtml(display.name)}`,
|
||||||
|
`<b>boundsPx:</b> ${escapeHtml(display.boundsPx)}`,
|
||||||
|
`<b>scale:</b> ${escapeHtml(display.scale)}`,
|
||||||
|
`<b>hz:</b> ${escapeHtml(display.hz)}`,
|
||||||
|
`<b>builtin:</b> ${escapeHtml(String(display.builtin))}`
|
||||||
|
].join(" · ")
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<h2>The Nook bug report</h2>`,
|
||||||
|
`<h3>“${escapeHtml(report.title)}”</h3>`,
|
||||||
|
report.description
|
||||||
|
? `<h3>What happened</h3><div>${escapeHtml(report.description).replace(/\r\n|\n|\r/g, "<br>")}</div>`
|
||||||
|
: "",
|
||||||
|
report.contact && contactRe.test(report.contact)
|
||||||
|
? `<p><b>Reply-to:</b> ${escapeHtml(report.contact)}</p>`
|
||||||
|
: "",
|
||||||
|
`<h3>Snapshot</h3>`,
|
||||||
|
report.appVersion ? `<p><b>${escapeHtml(report.appVersion)}</b></p>` : "",
|
||||||
|
machineLines.length > 0 ? `<p>${machineLines.join("<br>")}</p>` : "",
|
||||||
|
displayLines.length > 0
|
||||||
|
? `<h3>Displays</h3><div>${displayLines.map((line) => `<div>${line}</div>`).join("")}</div>`
|
||||||
|
: ""
|
||||||
|
]
|
||||||
|
.filter((chunk) => chunk !== "")
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(event: APIEvent): Promise<Response> {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return error("Invalid JSON", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = reportSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return error("Invalid report", 400);
|
||||||
|
}
|
||||||
|
const report = parsed.data;
|
||||||
|
if (report.contact && !contactRe.test(report.contact)) {
|
||||||
|
return error("Invalid reply-to email", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = (
|
||||||
|
event.request.headers.get("x-nook-fingerprint") ?? ""
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
// 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" }), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Retry-After": String(token.retryAfterSec ?? 3600)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `The Nook bug report: ${report.title.slice(0, 100)}`;
|
||||||
|
const result = await sendEmail(
|
||||||
|
CONTACT_RECIPIENT_EMAIL,
|
||||||
|
subject,
|
||||||
|
renderBody(report)
|
||||||
|
);
|
||||||
|
if (!result.success) {
|
||||||
|
return error("Failed to send report", 500);
|
||||||
|
}
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
79
src/routes/api/the-nook/resend-license.test.ts
Normal file
79
src/routes/api/the-nook/resend-license.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
||||||
|
|
||||||
|
// The route's two core contracts:
|
||||||
|
// 1. A license key is emailed only when the requested email owns one.
|
||||||
|
// 2. The HTTP response is identical whether or not a license exists —
|
||||||
|
// callers can't learn whether an email is registered.
|
||||||
|
// NookConnectionFactory and the nook helper module are mocked so the test
|
||||||
|
// needs no real DB, env, or outbound email.
|
||||||
|
|
||||||
|
let licenseRows: { key: string }[] = [];
|
||||||
|
const sentEmails: { to: string; key: string }[] = [];
|
||||||
|
const queries: string[] = [];
|
||||||
|
|
||||||
|
const fakeConn = {
|
||||||
|
execute: async (q: { sql: string; args?: unknown[] }) => {
|
||||||
|
queries.push(q.sql);
|
||||||
|
if (q.sql.includes("FROM licenses")) return { rows: licenseRows };
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mock.module("~/server/db-connections", () => ({
|
||||||
|
NookConnectionFactory: () => fakeConn
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("~/server/nook", () => ({
|
||||||
|
nookSchemaBootstrap: Promise.resolve(),
|
||||||
|
emailLicenseKey: async (to: string, key: string) => {
|
||||||
|
sentEmails.push({ to, key });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { POST } = await import("./resend-license");
|
||||||
|
|
||||||
|
function request(body: unknown): any {
|
||||||
|
return { request: { json: async () => body } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/the-nook/resend-license", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
licenseRows = [];
|
||||||
|
sentEmails.length = 0;
|
||||||
|
queries.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid email", async () => {
|
||||||
|
const res = await POST(request({ email: "not-an-email" }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: "Invalid email" });
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emails the stored key when a license exists for the email", async () => {
|
||||||
|
licenseRows = [{ key: "NOOK-ABC123" }];
|
||||||
|
const res = await POST(request({ email: "USER@Example.com " }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ success: true });
|
||||||
|
expect(sentEmails).toEqual([{ to: "user@example.com", key: "NOOK-ABC123" }]);
|
||||||
|
expect(queries.some((q) => q.toLowerCase().includes("lower(email)"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends nothing when no license exists, but still succeeds", async () => {
|
||||||
|
const res = await POST(request({ email: "nobody@example.com" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ success: true });
|
||||||
|
expect(sentEmails).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an identical response whether or not a license exists", async () => {
|
||||||
|
const withLicense = await POST(request({ email: "buyer@example.com" }));
|
||||||
|
licenseRows = [{ key: "NOOK-XYZ" }];
|
||||||
|
const withoutLicense = await POST(request({ email: "buyer@example.com" }));
|
||||||
|
|
||||||
|
expect(withLicense.status).toBe(withoutLicense.status);
|
||||||
|
expect(await withLicense.text()).toBe(await withoutLicense.text());
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/routes/api/the-nook/resend-license.ts
Normal file
42
src/routes/api/the-nook/resend-license.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { nookSchemaBootstrap, emailLicenseKey } from "~/server/nook";
|
||||||
|
import { json, error } from "./_lib";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/the-nook/resend-license
|
||||||
|
* Body: { email }
|
||||||
|
*
|
||||||
|
* Re-sends the most recent license key to a buyer who lost it. If the email
|
||||||
|
* owns a license, the key is emailed; otherwise nothing is sent. The response
|
||||||
|
* is identical either way so callers can't learn whether an email has a
|
||||||
|
* license — only validation failures differ.
|
||||||
|
*/
|
||||||
|
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 email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return error("Invalid email", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await nookSchemaBootstrap;
|
||||||
|
const conn = NookConnectionFactory();
|
||||||
|
|
||||||
|
const res = await conn.execute({
|
||||||
|
sql: "SELECT key FROM licenses WHERE lower(email) = lower(?) ORDER BY created_at DESC LIMIT 1",
|
||||||
|
args: [email]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.rows.length > 0) {
|
||||||
|
const { key } = res.rows[0] as { key: string };
|
||||||
|
await emailLicenseKey(email, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
}
|
||||||
77
src/routes/api/the-nook/trial-email.test.ts
Normal file
77
src/routes/api/the-nook/trial-email.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/routes/api/the-nook/trial-email.ts
Normal file
36
src/routes/api/the-nook/trial-email.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import { NookConnectionFactory } from "~/server/db-connections";
|
import { NookConnectionFactory } from "~/server/db-connections";
|
||||||
import { nookSchemaBootstrap, issueLicense } from "~/server/nook";
|
import { nookSchemaBootstrap, issueLicense, emailLicenseKey } from "~/server/nook";
|
||||||
|
import { emailThankYou } from "~/server/nook-lifecycle";
|
||||||
import { json } from "../_lib";
|
import { json } from "../_lib";
|
||||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|||||||
interface StripeSessionCompleted {
|
interface StripeSessionCompleted {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
customer_details?: { email?: string };
|
data?: { object?: { id?: string; customer_details?: { email?: string } } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean {
|
function verifyStripeSignature(rawBody: string, signatureHeader: string): boolean {
|
||||||
@@ -46,31 +47,6 @@ function verifyStripeSignature(rawBody: string, signatureHeader: string): boolea
|
|||||||
return timingSafeEqual(provided, expectedBuffer);
|
return timingSafeEqual(provided, expectedBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emailLicenseKey(to: string, licenseKey: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await fetch("https://api.brevo.com/v3/smtp/email", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"api-key": env.SENDINBLUE_KEY,
|
|
||||||
"content-type": "application/json",
|
|
||||||
accept: "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
sender: { email: env.EMAIL_FROM },
|
|
||||||
to: [{ email: to }],
|
|
||||||
subject: "Your The Nook license key",
|
|
||||||
textContent:
|
|
||||||
`Your The Nook license key is:\n\n${licenseKey}\n\n` +
|
|
||||||
`Open The Nook, go to Settings, and enter this key to activate.\n` +
|
|
||||||
`You can activate up to 3 devices.\n\n— The Nook`
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Purchase must not fail because email delivery did.
|
|
||||||
console.error("Failed to email The Nook license key:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(event: APIEvent) {
|
export async function POST(event: APIEvent) {
|
||||||
const rawBody = await event.request.text();
|
const rawBody = await event.request.text();
|
||||||
const signatureHeader = event.request.headers.get("Stripe-Signature");
|
const signatureHeader = event.request.headers.get("Stripe-Signature");
|
||||||
@@ -94,8 +70,8 @@ export async function POST(event: APIEvent) {
|
|||||||
return json({ received: true });
|
return json({ received: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = body.id;
|
const sessionId = body.data?.object?.id;
|
||||||
const email = body.customer_details?.email;
|
const email = body.data?.object?.customer_details?.email;
|
||||||
if (!sessionId || !email) {
|
if (!sessionId || !email) {
|
||||||
console.error("checkout.session.completed missing id or email:", rawBody);
|
console.error("checkout.session.completed missing id or email:", rawBody);
|
||||||
return json({ received: true });
|
return json({ received: true });
|
||||||
@@ -117,6 +93,9 @@ export async function POST(event: APIEvent) {
|
|||||||
try {
|
try {
|
||||||
const { key } = await issueLicense(email, sessionId);
|
const { key } = await issueLicense(email, sessionId);
|
||||||
await emailLicenseKey(email, key);
|
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) {
|
} catch (error) {
|
||||||
// UNIQUE stripe_session_id conflict from a racing duplicate delivery.
|
// UNIQUE stripe_session_id conflict from a racing duplicate delivery.
|
||||||
console.error("Failed to issue The Nook license (webhook):", error);
|
console.error("Failed to issue The Nook license (webhook):", error);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useSearchParams
|
useSearchParams
|
||||||
} from "@solidjs/router";
|
} from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import { getRequestEvent } from "solid-js/web";
|
import { getRequestEvent } from "solid-js/web";
|
||||||
import AuthenticatedLike from "~/components/blog/AuthenticatedLike";
|
import AuthenticatedLike from "~/components/blog/AuthenticatedLike";
|
||||||
@@ -20,6 +21,8 @@ import { api } from "~/lib/api";
|
|||||||
import CustomScrollbar from "~/components/CustomScrollbar";
|
import CustomScrollbar from "~/components/CustomScrollbar";
|
||||||
import "../post.css";
|
import "../post.css";
|
||||||
import { Post } from "~/db/types";
|
import { Post } from "~/db/types";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import NotFound from "../../[...404]";
|
||||||
|
|
||||||
const getPostByTitle = query(
|
const getPostByTitle = query(
|
||||||
async (
|
async (
|
||||||
@@ -261,6 +264,7 @@ const getPostByTitle = query(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function PostPage() {
|
export default function PostPage() {
|
||||||
|
const site = useSite();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -296,6 +300,12 @@ export default function PostPage() {
|
|||||||
return str.includes('data-type="mermaid"');
|
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 (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={data()}
|
when={data()}
|
||||||
@@ -327,6 +337,7 @@ export default function PostPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title={p().title.replaceAll("_", " ")}
|
title={p().title.replaceAll("_", " ")}
|
||||||
description={
|
description={
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Show } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { useSearchParams, A, query } from "@solidjs/router";
|
import { useSearchParams, A, query } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import PostSortingSelect from "~/components/blog/PostSortingSelect";
|
import PostSortingSelect from "~/components/blog/PostSortingSelect";
|
||||||
import TagSelector from "~/components/blog/TagSelector";
|
import TagSelector from "~/components/blog/TagSelector";
|
||||||
@@ -8,6 +9,8 @@ import PostSorting from "~/components/blog/PostSorting";
|
|||||||
import PublishStatusToggle from "~/components/blog/PublishStatusToggle";
|
import PublishStatusToggle from "~/components/blog/PublishStatusToggle";
|
||||||
import { TerminalSplash } from "~/components/TerminalSplash";
|
import { TerminalSplash } from "~/components/TerminalSplash";
|
||||||
import { CACHE_CONFIG } from "~/config";
|
import { CACHE_CONFIG } from "~/config";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import NotFound from "../[...404]";
|
||||||
|
|
||||||
const getPosts = query(async () => {
|
const getPosts = query(async () => {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -76,6 +79,7 @@ const getPosts = query(async () => {
|
|||||||
}, "posts");
|
}, "posts");
|
||||||
|
|
||||||
export default function BlogIndex() {
|
export default function BlogIndex() {
|
||||||
|
const site = useSite();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const sort = () => searchParams.sort || "newest";
|
const sort = () => searchParams.sort || "newest";
|
||||||
@@ -88,8 +92,18 @@ export default function BlogIndex() {
|
|||||||
|
|
||||||
const data = createAsync(() => getPosts(), { deferStream: true });
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Blog"
|
title="Blog"
|
||||||
description="Technical blog posts about web development, programming, and software engineering."
|
description="Technical blog posts about web development, programming, and software engineering."
|
||||||
|
|||||||
23
src/routes/changelog.tsx
Normal file
23
src/routes/changelog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useSearchParams } from "@solidjs/router";
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import RevealDropDown from "~/components/RevealDropDown";
|
import RevealDropDown from "~/components/RevealDropDown";
|
||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { buildSubdomainUrl } from "~/lib/site-context";
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
import { useSite } from "~/context/SiteContext";
|
import { useSite } from "~/context/SiteContext";
|
||||||
import NessaContactPage from "./nessa/contact";
|
import NessaContactPage from "./nessa/contact";
|
||||||
@@ -125,7 +126,9 @@ function MainContactPage() {
|
|||||||
const viewer = () => searchParams.viewer ?? "default";
|
const viewer = () => searchParams.viewer ?? "default";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContactForm
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
|
<ContactForm
|
||||||
subline={
|
subline={
|
||||||
<Show when={viewer() !== "lineage"}>
|
<Show when={viewer() !== "lineage"}>
|
||||||
(for this website or any of my apps...)
|
(for this website or any of my apps...)
|
||||||
@@ -134,6 +137,7 @@ function MainContactPage() {
|
|||||||
>
|
>
|
||||||
<LineageContactQuestions />
|
<LineageContactQuestions />
|
||||||
</ContactForm>
|
</ContactForm>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
|
import { createSignal, onMount, onCleanup, Switch, Match } from "solid-js";
|
||||||
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
||||||
@@ -71,6 +72,7 @@ function MainDownloadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Downloads"
|
title="Downloads"
|
||||||
description="Download The Nook, InputHalo, Gaze, Life and Lineage, Shapes with Abigail, and Cork. Available on macOS, iOS, and Android."
|
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">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<span class="text-subtext0 font-mono text-sm">
|
<span class="text-subtext0 font-mono text-sm">
|
||||||
platform: macOS (14.6+)
|
platform: macOS (14.2+)
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="download"
|
variant="download"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user