Compare commits
58 Commits
3149eba196
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
| 97755042cb | |||
| 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 |
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=""
|
||||||
|
|||||||
@@ -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
|
||||||
1
marketing/briefs/.gitkeep
Normal file
1
marketing/briefs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# briefs
|
||||||
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
|
||||||
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/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);
|
||||||
|
});
|
||||||
111
src/app.css
111
src/app.css
@@ -755,3 +755,114 @@ a.hover-underline-animation:hover::after {
|
|||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.35; }
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,34 @@ import {
|
|||||||
import {
|
import {
|
||||||
IslandPill,
|
IslandPill,
|
||||||
AgentDotGrid,
|
AgentDotGrid,
|
||||||
STATUS,
|
|
||||||
CALM,
|
CALM,
|
||||||
WARM,
|
WARM,
|
||||||
ACCENT,
|
ACCENT,
|
||||||
CRITICAL,
|
CRITICAL,
|
||||||
ModuleCard
|
ModuleCard,
|
||||||
|
FakeCursor
|
||||||
} from "./IslandMock";
|
} from "./IslandMock";
|
||||||
import { Campfire, type CampfireState } from "./Campfire";
|
import { Campfire, type CampfireState } from "./Campfire";
|
||||||
|
import {
|
||||||
|
ApprovalCardMock,
|
||||||
|
SessionRowMock,
|
||||||
|
CLAUDE,
|
||||||
|
CODEX
|
||||||
|
} from "./ModuleMocks";
|
||||||
|
import { useDemoLoop } from "./useDemoLoop";
|
||||||
|
import {
|
||||||
|
TerminalReplyDemo,
|
||||||
|
MixerDemo,
|
||||||
|
CaptureDemo,
|
||||||
|
FloatingActionsDemo
|
||||||
|
} from "./MoreDemos";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detailed feature breakdowns: alternating copy + live-demo sections.
|
* Detailed feature breakdowns: alternating copy + live-demo sections.
|
||||||
* Each demo stages the real interaction loop: attention flares, approval
|
* Each demo stages the real interaction loop: attention flares, approval
|
||||||
* resolves, fans throttle, the charge ceiling holds, meetings surface,
|
* resolves, fans throttle, the charge ceiling holds, meetings surface,
|
||||||
* the camera wakes, the hook installs.
|
* the camera wakes. Newer demos live in
|
||||||
|
* MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Scroll-triggered reveal: adds `in` once the element crosses 25%. */
|
/* Scroll-triggered reveal: adds `in` once the element crosses 25%. */
|
||||||
@@ -165,100 +179,88 @@ function FleetDemo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Demo 2: permission card resolves, phone push follows ─────────── */
|
/* ── Demo 2: an approval surfaces the island; Allow resumes the run ── */
|
||||||
|
|
||||||
function ApprovalDemo() {
|
function ApprovalDemo() {
|
||||||
const [allowed, setAllowed] = createSignal(false);
|
const [phase, setPhase] = createSignal<"calm" | "ask" | "press" | "resumed">("calm");
|
||||||
onMount(() => {
|
useDemoLoop(10400, [
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
[0, () => setPhase("calm")],
|
||||||
const loop = () => {
|
[1400, () => setPhase("ask")],
|
||||||
setAllowed(false);
|
[4600, () => setPhase("press")],
|
||||||
timers.push(setTimeout(() => setAllowed(true), 4200));
|
[4950, () => setPhase("resumed")],
|
||||||
};
|
[8800, () => setPhase("calm")]
|
||||||
loop();
|
]);
|
||||||
const interval = setInterval(loop, 8400);
|
const asking = () => phase() === "ask" || phase() === "press";
|
||||||
onCleanup(() => {
|
const open = () => phase() !== "calm";
|
||||||
clearInterval(interval);
|
|
||||||
timers.forEach(clearTimeout);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col items-center gap-5 sm:flex-row sm:items-end sm:justify-center">
|
<div class="relative flex h-[390px] flex-col items-center gap-3">
|
||||||
{/* island card */}
|
<IslandPill
|
||||||
<div
|
campfire={asking() ? "question" : "running"}
|
||||||
class="w-full max-w-[300px] rounded-[10px] p-2.5 text-left transition-opacity duration-500"
|
right={
|
||||||
style={{
|
<AgentDotGrid
|
||||||
background: "rgba(234,179,8,0.08)",
|
states={asking() ? ["attention", "running", "ready"] : ["running", "running", "ready"]}
|
||||||
opacity: allowed() ? 0.45 : 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="mb-1.5 flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
class="inline-block h-[13px] w-[13px] rounded-[3px]"
|
|
||||||
style={{ background: "#D97742" }}
|
|
||||||
/>
|
/>
|
||||||
<span class="text-[12px] font-semibold text-white">
|
}
|
||||||
the-nook · bridge
|
/>
|
||||||
</span>
|
{/* the island opens: approval pinned above the session rows */}
|
||||||
<span
|
|
||||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
|
||||||
style={{
|
|
||||||
background: allowed()
|
|
||||||
? "rgba(150,220,150,0.25)"
|
|
||||||
: "rgba(234,179,8,0.25)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allowed() ? "allowed" : "approval"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-[13px] font-medium text-white">Run Edit</p>
|
|
||||||
<p class="mt-0.5 font-mono text-[11px] text-white/50">
|
|
||||||
server/routes.ts +12 −4
|
|
||||||
</p>
|
|
||||||
<div class="mt-2.5 flex gap-2">
|
|
||||||
<span
|
|
||||||
class="rounded-md px-3 py-1 text-[11px] font-semibold text-white transition-all duration-300"
|
|
||||||
style={{ background: ACCENT, opacity: allowed() ? 0.4 : 1 }}
|
|
||||||
>
|
|
||||||
Allow
|
|
||||||
</span>
|
|
||||||
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
|
|
||||||
Deny
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* phone */}
|
|
||||||
<div
|
<div
|
||||||
class="w-[150px] rounded-2xl border border-white/15 p-2 text-left"
|
class="w-full max-w-[380px] overflow-hidden rounded-[14px] bg-black transition-all duration-700"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(255,255,255,0.04)",
|
"max-height": open() ? "320px" : "0px",
|
||||||
transform: allowed() ? "translateY(-4px)" : "translateY(0)",
|
opacity: open() ? 1 : 0,
|
||||||
transition: "transform 0.5s ease"
|
transform: open() ? "translateY(0)" : "translateY(-8px)"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="mx-auto mb-1.5 h-1 w-10 rounded-full bg-white/20" />
|
<div class="p-2.5">
|
||||||
|
<div
|
||||||
|
class="grid transition-all duration-500"
|
||||||
|
style={{ "grid-template-rows": asking() ? "1fr" : "0fr", opacity: asking() ? 1 : 0 }}
|
||||||
|
>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div class="pb-1.5">
|
||||||
|
<ApprovalCardMock
|
||||||
|
agent={CLAUDE}
|
||||||
|
title="the-nook · harden the bridge"
|
||||||
|
tool="Bash"
|
||||||
|
input="swift test --filter Bridge"
|
||||||
|
pressed={phase() === "press" ? "allow" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<p class="px-1 text-[9px] font-semibold tracking-wide text-white/40 uppercase">
|
<SessionRowMock
|
||||||
The Nook
|
title="the-nook · main"
|
||||||
</p>
|
prompt="harden the bridge"
|
||||||
<div
|
state={
|
||||||
class="rounded-lg p-2"
|
asking()
|
||||||
style={{
|
? "Approve Bash — swift test --filter Bridge?"
|
||||||
background: allowed()
|
: "Running swift test --filter Bridge…"
|
||||||
? "rgba(111,185,130,0.14)"
|
}
|
||||||
: "rgba(231,167,98,0.14)"
|
status={asking() ? "attention" : "running"}
|
||||||
}}
|
agent={CLAUDE}
|
||||||
>
|
origin="iTerm2"
|
||||||
<p class="text-[10px] font-semibold text-white">
|
age="6m"
|
||||||
{allowed() ? "Allowed ✓" : "Claude Code wants to edit"}
|
/>
|
||||||
</p>
|
<SessionRowMock
|
||||||
<p class="text-[9px] text-white/50">
|
title="site · nook-334"
|
||||||
{allowed() ? "resumed" : "tap to Allow or Deny"}
|
prompt="match the mocks to the app"
|
||||||
</p>
|
state="Reading ModuleMocks.tsx…"
|
||||||
|
status="running"
|
||||||
|
agent={CODEX}
|
||||||
|
origin="kitty-tmux"
|
||||||
|
age="2m"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-[380px] -translate-x-1/2">
|
||||||
|
<FakeCursor
|
||||||
|
phase={phase() === "press" ? "press" : asking() ? "idle" : "live"}
|
||||||
|
at={{ x: 9.5, y: 41 }}
|
||||||
|
exit={{ x: 30, y: 70 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -406,37 +408,6 @@ function FansDemo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fake cursor dot: rests on a target, presses, exits. */
|
|
||||||
function FakeCursor(props: {
|
|
||||||
phase: "idle" | "press" | "live";
|
|
||||||
at: { x: number; y: number };
|
|
||||||
exit?: { x: number; y: number };
|
|
||||||
}) {
|
|
||||||
const p = () =>
|
|
||||||
props.phase === "press"
|
|
||||||
? { ...props.at, scale: 0.62, o: 1 }
|
|
||||||
: props.phase === "idle"
|
|
||||||
? { ...props.at, scale: 1, o: 1 }
|
|
||||||
: { ...(props.exit ?? { x: 106, y: 8 }), scale: 1, o: 0 };
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute z-10 rounded-full bg-white"
|
|
||||||
style={{
|
|
||||||
width: "9px",
|
|
||||||
height: "9px",
|
|
||||||
"box-shadow":
|
|
||||||
"0 1px 4px rgba(0,0,0,0.6), 0 0 0 4px rgba(255,255,255,0.18)",
|
|
||||||
left: `${p().x}%`,
|
|
||||||
top: `${p().y}%`,
|
|
||||||
opacity: p().o,
|
|
||||||
transform: `translate(-50%, -50%) scale(${p().scale})`,
|
|
||||||
transition:
|
|
||||||
"left 0.8s cubic-bezier(0.35,0,0.25,1), top 0.8s cubic-bezier(0.35,0,0.25,1), opacity 0.4s, transform 0.3s"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Demo 4: the charge ceiling holds, top-up overrules ───── */
|
/* ── Demo 4: the charge ceiling holds, top-up overrules ───── */
|
||||||
|
|
||||||
function BatteryDemo() {
|
function BatteryDemo() {
|
||||||
@@ -764,8 +735,15 @@ function CameraDemo() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (phase() === "live") videoRef?.play();
|
if (phase() === "live" && videoRef) {
|
||||||
else videoRef?.pause();
|
videoRef.currentTime = 0;
|
||||||
|
// play() rejects with AbortError when interrupted by pause() (the loop
|
||||||
|
// resetting to "idle") or when the browser pauses background media to
|
||||||
|
// save power. Swallow it — the rejection is expected, not a failure.
|
||||||
|
void videoRef.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
videoRef?.pause();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<ModuleCard class="relative mx-auto w-full max-w-[300px]">
|
<ModuleCard class="relative mx-auto w-full max-w-[300px]">
|
||||||
@@ -809,9 +787,9 @@ function CameraDemo() {
|
|||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src="/nook/cam-recording.mp4"
|
src="/nook/cam-recording.mp4"
|
||||||
muted
|
muted
|
||||||
loop
|
|
||||||
playsinline
|
playsinline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
onEnded={() => setPhase("idle")}
|
||||||
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-700"
|
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-700"
|
||||||
style={{ opacity: phase() === "live" ? 1 : 0 }}
|
style={{ opacity: phase() === "live" ? 1 : 0 }}
|
||||||
/>
|
/>
|
||||||
@@ -827,145 +805,6 @@ function CameraDemo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Demo 7: one click copies, sets up, probes — pong ─────── */
|
|
||||||
|
|
||||||
const SSH_LINES = [
|
|
||||||
{ at: 1, text: "scp nook-hook gpu-rig:~/.nook/bin/" },
|
|
||||||
{ at: 2, text: "setup: bake NOOK_HOST, write ~/.nook/endpoint" },
|
|
||||||
{ at: 3, text: "nook-hook --probe → pong" }
|
|
||||||
];
|
|
||||||
|
|
||||||
function SshInstallDemo() {
|
|
||||||
const [step, setStep] = createSignal(0);
|
|
||||||
onMount(() => {
|
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
||||||
const loop = () => {
|
|
||||||
setStep(0);
|
|
||||||
timers.push(setTimeout(() => setStep(1), 1600));
|
|
||||||
timers.push(setTimeout(() => setStep(2), 3000));
|
|
||||||
timers.push(setTimeout(() => setStep(3), 4200));
|
|
||||||
timers.push(setTimeout(() => setStep(4), 5400));
|
|
||||||
};
|
|
||||||
loop();
|
|
||||||
const interval = setInterval(loop, 11000);
|
|
||||||
onCleanup(() => {
|
|
||||||
clearInterval(interval);
|
|
||||||
timers.forEach(clearTimeout);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const cursorPhase = () =>
|
|
||||||
step() === 0
|
|
||||||
? ("idle" as const)
|
|
||||||
: step() === 1
|
|
||||||
? ("press" as const)
|
|
||||||
: ("live" as const);
|
|
||||||
return (
|
|
||||||
<ModuleCard class="relative mx-auto w-full max-w-[330px]">
|
|
||||||
<div class="space-y-2.5 text-left">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
|
|
||||||
<p class="truncate font-mono text-[10px] text-white/40">
|
|
||||||
mike@192.168.64.3 · linux
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-300"
|
|
||||||
style={{
|
|
||||||
background: step() === 0 ? ACCENT : "rgba(255,255,255,0.08)",
|
|
||||||
opacity: step() >= 4 ? 0 : 1,
|
|
||||||
transform: step() === 1 ? "scale(0.94)" : "scale(1)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{step() === 0 ? "Install hook" : "installing…"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
|
||||||
<div class="relative" style={{ "min-height": "86px" }}>
|
|
||||||
<div
|
|
||||||
class="space-y-1.5 transition-opacity duration-500"
|
|
||||||
style={{ opacity: step() >= 4 ? 0 : 1 }}
|
|
||||||
>
|
|
||||||
{step() === 0 && (
|
|
||||||
<p class="pt-2 text-center text-[10px] text-white/35">
|
|
||||||
one click: copy → setup → probe
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<For each={SSH_LINES}>
|
|
||||||
{(l) => (
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1.5 transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
opacity: step() >= l.at ? 1 : 0,
|
|
||||||
transform:
|
|
||||||
step() >= l.at ? "translateY(0)" : "translateY(4px)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="font-mono text-[10px]"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
step() > l.at
|
|
||||||
? "rgba(150,220,150,0.9)"
|
|
||||||
: "rgba(255,255,255,0.45)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{step() > l.at ? "✓" : "…"}
|
|
||||||
</span>
|
|
||||||
<span class="truncate font-mono text-[10px] text-white/70">
|
|
||||||
{l.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 flex flex-col justify-center transition-all duration-500"
|
|
||||||
style={{
|
|
||||||
opacity: step() >= 4 ? 1 : 0,
|
|
||||||
transform: step() >= 4 ? "translateY(0)" : "translateY(6px)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="rounded-[10px] p-2.5"
|
|
||||||
style={{
|
|
||||||
background: "rgba(255,255,255,0.055)",
|
|
||||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="inline-block h-[7px] w-[7px] rounded-full"
|
|
||||||
style={{ background: STATUS.running }}
|
|
||||||
/>
|
|
||||||
<span class="text-[13px] font-semibold text-white">
|
|
||||||
gpu-rig
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold"
|
|
||||||
style={{
|
|
||||||
background: "rgba(110,167,255,0.22)",
|
|
||||||
color: "#9cc3ff"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
remote
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-0.5 text-[11px] text-white/50">
|
|
||||||
You: benchmark fp16
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p class="pt-1.5 text-center text-[10px] text-white/40">
|
|
||||||
agents join the same dot grid
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FakeCursor phase={cursorPhase()} at={{ x: 90, y: 14 }} />
|
|
||||||
</ModuleCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Demo 8: campfire states ───────────────────────────────────────── */
|
/* ── Demo 8: campfire states ───────────────────────────────────────── */
|
||||||
|
|
||||||
function CampfireDemo() {
|
function CampfireDemo() {
|
||||||
@@ -1012,8 +851,8 @@ export default function FeatureBreakdowns() {
|
|||||||
title="Your whole fleet, in one pixel strip"
|
title="Your whole fleet, in one pixel strip"
|
||||||
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
||||||
bullets={[
|
bullets={[
|
||||||
"12 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, and more",
|
"18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
||||||
"The campfire dances while work runs, simmers when all is calm",
|
"Custom agents: your own coding agent can wire itself in from the published contract",
|
||||||
"Slot rankings — you choose which data earns the notch",
|
"Slot rankings — you choose which data earns the notch",
|
||||||
"You don't need a notch — external displays get the same island, floating top-center"
|
"You don't need a notch — external displays get the same island, floating top-center"
|
||||||
]}
|
]}
|
||||||
@@ -1024,17 +863,43 @@ export default function FeatureBreakdowns() {
|
|||||||
<BreakdownSection
|
<BreakdownSection
|
||||||
kicker="Approvals"
|
kicker="Approvals"
|
||||||
title="Answer from the island — or your phone"
|
title="Answer from the island — or your phone"
|
||||||
body="Gated tool calls hold right in the panel. Allow, deny, or demand a reason. Walking away? The same request lands on your phone and your tap flies back to the agent."
|
body="A gated tool call surfaces the island: the fire turns to a question, the card pins above your sessions, and the row says what it's waiting on. Allow, Allow for session, Deny, or deny with a reason, and the run picks up where it stopped."
|
||||||
bullets={[
|
bullets={[
|
||||||
"Permission and question cards pin above the session list",
|
"Approval cards pin above the session list; questions answer inside their row",
|
||||||
"Push-to-phone over your own network — no cloud middleman",
|
"Allow for session waves through the rest of that session's requests",
|
||||||
"Works across every hooked agent, local or remote"
|
"Answer from iPhone or Apple Watch too, now in TestFlight beta",
|
||||||
]}
|
]}
|
||||||
reversed
|
reversed
|
||||||
>
|
>
|
||||||
<ApprovalDemo />
|
<ApprovalDemo />
|
||||||
</BreakdownSection>
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="Terminals"
|
||||||
|
title="Reply without hunting for the tab"
|
||||||
|
body="Every session knows the terminal it runs in. Type a reply in the island and it lands in that exact pane; click Jump and the right window comes forward."
|
||||||
|
bullets={[
|
||||||
|
"Replies reach agents in tmux, iTerm2, Terminal, and kitty",
|
||||||
|
"Jump focuses the exact window, even for sessions over SSH",
|
||||||
|
"Per-turn replies render as Markdown, with tables and code"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TerminalReplyDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="The campfire"
|
||||||
|
title="A hearth that tells the truth"
|
||||||
|
body="Stop-motion pixel art, four frames a step. It dances when tokens stream, putters blue when an agent asks, and goes to dead embers the moment something breaks."
|
||||||
|
bullets={[
|
||||||
|
"Idle, running, ready, question, and error — at a glance",
|
||||||
|
"Hand-drawn 14×14 sprites, no tweening",
|
||||||
|
"Six chiptunes you can assign to any agent event"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CampfireDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
<BreakdownSection
|
<BreakdownSection
|
||||||
kicker="Cooling"
|
kicker="Cooling"
|
||||||
title="Fans that answer to you, not the scheduler"
|
title="Fans that answer to you, not the scheduler"
|
||||||
@@ -1045,6 +910,7 @@ export default function FeatureBreakdowns() {
|
|||||||
"CPU/GPU die temps tier-color before things get loud",
|
"CPU/GPU die temps tier-color before things get loud",
|
||||||
"All local: your hardware data never leaves the device"
|
"All local: your hardware data never leaves the device"
|
||||||
]}
|
]}
|
||||||
|
reversed
|
||||||
>
|
>
|
||||||
<FansDemo />
|
<FansDemo />
|
||||||
</BreakdownSection>
|
</BreakdownSection>
|
||||||
@@ -1060,11 +926,38 @@ export default function FeatureBreakdowns() {
|
|||||||
"Thermal protection pauses charging when the pack runs hot",
|
"Thermal protection pauses charging when the pack runs hot",
|
||||||
"Energy flow figure: adapter → battery → system, live watts"
|
"Energy flow figure: adapter → battery → system, live watts"
|
||||||
]}
|
]}
|
||||||
reversed
|
|
||||||
>
|
>
|
||||||
<BatteryDemo />
|
<BatteryDemo />
|
||||||
</BreakdownSection>
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="Sound"
|
||||||
|
title="Every app gets its own fader"
|
||||||
|
body="A real volume mixer: set each app's level, mute the noisy one, and send any app to its own output — music to headphones while the meeting stays on the speakers."
|
||||||
|
bullets={[
|
||||||
|
"Per-app volume, mute, and output routing",
|
||||||
|
"Master output, system sounds, and microphone pickers in one panel",
|
||||||
|
"Catches players that aren't apps, like command-line podcast tools"
|
||||||
|
]}
|
||||||
|
reversed
|
||||||
|
>
|
||||||
|
<MixerDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
|
<BreakdownSection
|
||||||
|
kicker="Capture"
|
||||||
|
title="Screenshots and recordings from the notch"
|
||||||
|
body="Drag out an area, pick a screen, or choose one app. While a recording runs, Wings unfold beside the island: pause and stop on the right, system audio and microphone on the left."
|
||||||
|
bullets={[
|
||||||
|
"Area, screen, or single-app capture",
|
||||||
|
"Screenshots save to your screenshot folder and copy to the clipboard",
|
||||||
|
"System audio and microphone tracks for recordings",
|
||||||
|
"The Nook's own windows never show up in a capture"
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CaptureDemo />
|
||||||
|
</BreakdownSection>
|
||||||
|
|
||||||
<BreakdownSection
|
<BreakdownSection
|
||||||
kicker="Your day"
|
kicker="Your day"
|
||||||
title="Meetings come to the notch"
|
title="Meetings come to the notch"
|
||||||
@@ -1075,6 +968,7 @@ export default function FeatureBreakdowns() {
|
|||||||
"Meeting links get an Open button in the row",
|
"Meeting links get an Open button in the row",
|
||||||
"Real calendars and reminders — no sync service"
|
"Real calendars and reminders — no sync service"
|
||||||
]}
|
]}
|
||||||
|
reversed
|
||||||
>
|
>
|
||||||
<CalendarDemo />
|
<CalendarDemo />
|
||||||
</BreakdownSection>
|
</BreakdownSection>
|
||||||
@@ -1089,35 +983,23 @@ export default function FeatureBreakdowns() {
|
|||||||
"A device picker for every connected camera",
|
"A device picker for every connected camera",
|
||||||
"Opt-in module — in no default panel"
|
"Opt-in module — in no default panel"
|
||||||
]}
|
]}
|
||||||
reversed
|
|
||||||
>
|
>
|
||||||
<CameraDemo />
|
<CameraDemo />
|
||||||
</BreakdownSection>
|
</BreakdownSection>
|
||||||
|
|
||||||
<BreakdownSection
|
<BreakdownSection
|
||||||
kicker="Remote"
|
kicker="Floating actions"
|
||||||
title="Any SSH box, one click away"
|
title="Your buttons, orbiting the island"
|
||||||
body="Point at any SSH-reachable box and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
|
body="Pin up to seven one-tap actions around the expanded island. They ride its expansion in and out, so the things you reach for most are always one click away."
|
||||||
bullets={[
|
bullets={[
|
||||||
"One click: copy the hook, run setup, probe answers pong",
|
"Run an Apple Shortcut, open an app or URL, or run a shell command",
|
||||||
"Remote agents wear a blue badge in the same dot grid",
|
"Toggle Low Power Mode, top-up, or overheat protection",
|
||||||
"Works for Claude, Codex, Pi and many more"
|
"Set the charge limit, cycle audio outputs, or start a timer",
|
||||||
|
"Cycle the approval mode without opening Settings"
|
||||||
]}
|
]}
|
||||||
|
reversed
|
||||||
>
|
>
|
||||||
<SshInstallDemo />
|
<FloatingActionsDemo />
|
||||||
</BreakdownSection>
|
|
||||||
|
|
||||||
<BreakdownSection
|
|
||||||
kicker="The campfire"
|
|
||||||
title="A hearth that tells the truth"
|
|
||||||
body="Stop-motion pixel art, four frames a step. It dances when tokens stream, putters blue when an agent asks, and goes to dead embers the moment something breaks."
|
|
||||||
bullets={[
|
|
||||||
"Idle, running, ready, question, and error — at a glance",
|
|
||||||
"Hand-drawn 14×14 sprites, no tweening",
|
|
||||||
"The fire is the fleet's pulse: glanceable from across the room"
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<CampfireDemo />
|
|
||||||
</BreakdownSection>
|
</BreakdownSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { For, type JSX } from "solid-js";
|
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
||||||
import {
|
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
|
||||||
IslandPill,
|
import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
|
||||||
AgentDotGrid,
|
|
||||||
STATUS,
|
|
||||||
CALM,
|
|
||||||
CRITICAL,
|
|
||||||
ACCENT,
|
|
||||||
ModuleCard
|
|
||||||
} from "./IslandMock";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature collage: five cells sold as the app's actual UI. The hero cell
|
* Feature collage: five cells sold as the app's actual UI. The hero cell
|
||||||
@@ -16,7 +9,7 @@ import {
|
|||||||
* module chrome, status palette, and metric language.
|
* module chrome, status palette, and metric language.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function CollageCell(props: {
|
export function CollageCell(props: {
|
||||||
class?: string;
|
class?: string;
|
||||||
kicker: string;
|
kicker: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -44,54 +37,13 @@ function CollageCell(props: {
|
|||||||
* is always dark (preferredColorScheme .dark), so the mocks read correctly
|
* is always dark (preferredColorScheme .dark), so the mocks read correctly
|
||||||
* in both site themes.
|
* in both site themes.
|
||||||
*/
|
*/
|
||||||
function InkStage(props: { children: JSX.Element; class?: string }) {
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The approvals mock rebuilt on the app's PermissionCard anatomy. */
|
|
||||||
function ApprovalCardMock() {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="mx-auto w-full max-w-sm rounded-[10px] p-2.5 text-left"
|
class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`}
|
||||||
style={{ background: "rgba(234,179,8,0.08)" }}
|
style={{ background: "#0d0d0f" }}
|
||||||
>
|
>
|
||||||
<div class="mb-1.5 flex items-center gap-1.5">
|
{props.children}
|
||||||
<span
|
|
||||||
class="inline-block h-[13px] w-[13px] rounded-[3px]"
|
|
||||||
style={{ background: "#D97742" }}
|
|
||||||
/>
|
|
||||||
<span class="text-[12px] font-semibold text-white">
|
|
||||||
the-nook · bridge
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
|
||||||
style={{ background: "rgba(234,179,8,0.25)" }}
|
|
||||||
>
|
|
||||||
approval
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-[13px] font-medium text-white">Run Edit</p>
|
|
||||||
<p class="mt-0.5 font-mono text-[11px] text-white/50">
|
|
||||||
server/routes.ts +12 −4
|
|
||||||
</p>
|
|
||||||
<div class="mt-2.5 flex gap-2">
|
|
||||||
<span
|
|
||||||
class="rounded-md px-3 py-1 text-[11px] font-semibold"
|
|
||||||
style={{ background: ACCENT }}
|
|
||||||
>
|
|
||||||
Allow
|
|
||||||
</span>
|
|
||||||
<span class="rounded-md border border-white/20 px-3 py-1 text-[11px] font-semibold text-white/80">
|
|
||||||
Deny
|
|
||||||
</span>
|
|
||||||
<span class="px-1 py-1 text-[11px] text-white/50">
|
|
||||||
Deny with reason…
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -147,97 +99,37 @@ function FansPanelMock() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remote agents: two session rows like the expanded agents panel. */
|
/* Deterministic usage wander for the traces. */
|
||||||
function RemoteMock() {
|
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 (
|
return (
|
||||||
<div class="space-y-1.5">
|
<ModuleCard>
|
||||||
<div
|
<div class="space-y-2">
|
||||||
class="rounded-[10px] p-2.5"
|
<DieLine label="CPU" usage={trace(0)} celsius={58 + 4 * Math.sin(n() * 0.3)} />
|
||||||
style={{ background: "rgba(255,255,255,0.055)", "box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)" }}
|
<DieLine label="GPU" usage={trace(2)} celsius={gpuCelsius()} />
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="inline-block h-[7px] w-[7px] rounded-full"
|
|
||||||
style={{ background: STATUS.running }}
|
|
||||||
/>
|
|
||||||
<span class="text-[13px] font-semibold text-white">build-box</span>
|
|
||||||
<span
|
|
||||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
|
|
||||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
|
||||||
>
|
|
||||||
ssh
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-0.5 text-[11px] text-white/50">You: fix flaky test</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="rounded-[10px] p-2.5"
|
|
||||||
style={{ background: "rgba(255,255,255,0.055)", "box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)" }}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="inline-block h-[7px] w-[7px] rounded-full"
|
|
||||||
style={{ background: STATUS.ready }}
|
|
||||||
/>
|
|
||||||
<span class="text-[13px] font-semibold text-white">gpu-rig</span>
|
|
||||||
<span
|
|
||||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white/75"
|
|
||||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
|
||||||
>
|
|
||||||
ssh
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-0.5 text-[11px] text-white/50">Ready</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** System gauges: temp die blocks + memory, in module cards. */
|
|
||||||
function GaugesMock() {
|
|
||||||
return (
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<ModuleCard>
|
|
||||||
<div class="flex items-baseline justify-between">
|
|
||||||
<span class="text-[9px] font-semibold text-white/50">CPU</span>
|
|
||||||
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
|
|
||||||
72°
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mt-1.5 h-1 overflow-hidden rounded-full"
|
|
||||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full"
|
|
||||||
style={{ width: "64%", background: CALM }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ModuleCard>
|
|
||||||
<ModuleCard>
|
|
||||||
<div class="flex items-baseline justify-between">
|
|
||||||
<span class="text-[9px] font-semibold text-white/50">GPU</span>
|
|
||||||
<span class="font-mono text-white" style={{ "font-size": "15px" }}>
|
|
||||||
58°
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mt-1.5 h-1 overflow-hidden rounded-full"
|
|
||||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full"
|
|
||||||
style={{ width: "31%", background: CALM }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ModuleCard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export default function FeatureCollage() {
|
export default function FeatureCollage() {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
class="bg-base relative z-20 -mt-28 px-4 pb-16 pt-40 md:px-8"
|
class="bg-base relative z-20 px-4 pt-40 pb-16 md:px-8"
|
||||||
id="feature-collage"
|
id="feature-collage"
|
||||||
>
|
>
|
||||||
<div class="mx-auto max-w-5xl">
|
<div class="mx-auto max-w-5xl">
|
||||||
@@ -250,9 +142,7 @@ export default function FeatureCollage() {
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||||
{/* Hero: real collapsed pill on the island surface */}
|
{/* Hero: real collapsed pill on the island surface */}
|
||||||
<div
|
<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">
|
||||||
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">
|
<div class="mb-5 self-start">
|
||||||
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||||
The island
|
The island
|
||||||
@@ -261,9 +151,9 @@ export default function FeatureCollage() {
|
|||||||
Every agent, one glance
|
Every agent, one glance
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
||||||
A pill tucked into your notch — a campfire for your whole
|
A pill tucked into your notch — a campfire for your whole fleet
|
||||||
fleet on the left, live data on the right. Blue running,
|
on the left, live data on the right. Blue running, green ready,
|
||||||
green ready, amber needs you.
|
amber needs you.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="my-10 flex w-full justify-center">
|
<div class="my-10 flex w-full justify-center">
|
||||||
@@ -304,11 +194,16 @@ export default function FeatureCollage() {
|
|||||||
<CollageCell
|
<CollageCell
|
||||||
kicker="Approvals"
|
kicker="Approvals"
|
||||||
title="Unblock agents, anywhere"
|
title="Unblock agents, anywhere"
|
||||||
body="Permission prompts surface as cards in the panel — Allow once, always, or deny. Away from the desk, push them to your phone and answer from there."
|
body="Permission prompts pin as cards above your sessions — Allow, Allow for session, or Deny. Away from the desk, answer from your iPhone or Apple Watch (beta)."
|
||||||
>
|
>
|
||||||
<InkStage>
|
<InkStage>
|
||||||
<div class="w-full max-w-sm px-3">
|
<div class="w-full max-w-sm px-3">
|
||||||
<ApprovalCardMock />
|
<ApprovalCardMock
|
||||||
|
agent={CLAUDE}
|
||||||
|
title="the-nook · bridge"
|
||||||
|
tool="Edit"
|
||||||
|
input="server/routes.ts"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</InkStage>
|
</InkStage>
|
||||||
</CollageCell>
|
</CollageCell>
|
||||||
@@ -326,24 +221,11 @@ export default function FeatureCollage() {
|
|||||||
</InkStage>
|
</InkStage>
|
||||||
</CollageCell>
|
</CollageCell>
|
||||||
|
|
||||||
{/* Remote agents */}
|
|
||||||
<CollageCell
|
|
||||||
kicker="Remote"
|
|
||||||
title="Agents on other machines"
|
|
||||||
body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island."
|
|
||||||
>
|
|
||||||
<InkStage class="w-full">
|
|
||||||
<div class="w-full max-w-[280px] text-left">
|
|
||||||
<RemoteMock />
|
|
||||||
</div>
|
|
||||||
</InkStage>
|
|
||||||
</CollageCell>
|
|
||||||
|
|
||||||
{/* Gauges */}
|
{/* Gauges */}
|
||||||
<CollageCell
|
<CollageCell
|
||||||
kicker="Your Mac"
|
kicker="Your Mac"
|
||||||
title="The gauges that matter"
|
title="The gauges that matter"
|
||||||
body="CPU and GPU die temps, memory pressure, live network throughput — the same tier palette the island uses, warning before a compile cooks your lap."
|
body="Each die's temperature beside its 60-second usage trace, tinted amber from 70° and red at 85°, so you see a compile heating up before the fans do."
|
||||||
>
|
>
|
||||||
<InkStage class="w-full">
|
<InkStage class="w-full">
|
||||||
<div class="w-full max-w-[300px] text-left">
|
<div class="w-full max-w-[300px] text-left">
|
||||||
@@ -354,8 +236,13 @@ export default function FeatureCollage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-subtext1 mt-6 text-center text-xs">
|
<p class="text-subtext1 mt-6 text-center text-xs">
|
||||||
Plus calendar, reminders, calls, and Now Playing — all in the same
|
Plus a mixer, capture, timers, weather, and a dozen more modules —{" "}
|
||||||
island, all one-time purchase.
|
<a
|
||||||
|
href="#island-extras"
|
||||||
|
class="underline decoration-dotted hover:opacity-80"
|
||||||
|
>
|
||||||
|
see them all ↓
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,9 +85,10 @@ function IslandSurface(props: { children: JSX.Element; class?: string }) {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
const node = el;
|
const node = el;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
// Layout size, not getBoundingClientRect: a scaled-down mock (the
|
||||||
|
// Capture demo's island row) would otherwise clip itself to a corner.
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
const r = node.getBoundingClientRect();
|
node.style.clipPath = `path("${islandPath(node.offsetWidth, node.offsetHeight)}")`;
|
||||||
node.style.clipPath = `path("${islandPath(r.width, r.height)}")`;
|
|
||||||
};
|
};
|
||||||
apply();
|
apply();
|
||||||
const observer = new ResizeObserver(apply);
|
const observer = new ResizeObserver(apply);
|
||||||
@@ -261,3 +262,34 @@ export function IslandExpanded(props: {
|
|||||||
</div>
|
</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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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: {
|
||||||
|
|||||||
@@ -120,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"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ describe("generateSitemap", () => {
|
|||||||
expect(locs).toContain("https://nessa.freno.me/");
|
expect(locs).toContain("https://nessa.freno.me/");
|
||||||
expect(locs).toContain("https://nessa.freno.me/contact");
|
expect(locs).toContain("https://nessa.freno.me/contact");
|
||||||
expect(locs).toContain("https://nessa.freno.me/privacy");
|
expect(locs).toContain("https://nessa.freno.me/privacy");
|
||||||
expect(locs.length).toBe(3);
|
expect(locs).toContain("https://nessa.freno.me/terms");
|
||||||
|
expect(locs.length).toBe(4);
|
||||||
|
|
||||||
for (const loc of locs) {
|
for (const loc of locs) {
|
||||||
expect(loc).not.toContain("://freno.me/");
|
expect(loc).not.toContain("://freno.me/");
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
|||||||
nessa: [
|
nessa: [
|
||||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||||
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
||||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
{ path: "/privacy", changefreq: "yearly", priority: 0.4 },
|
||||||
|
{ path: "/terms", changefreq: "yearly", priority: 0.4 }
|
||||||
],
|
],
|
||||||
|
|
||||||
lineage: [
|
lineage: [
|
||||||
@@ -83,6 +84,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));
|
||||||
@@ -5,10 +5,13 @@ import { describe, expect, it, mock, beforeEach } from "bun:test";
|
|||||||
// and description, HTML-escaped.
|
// and description, HTML-escaped.
|
||||||
// 2. Invalid payloads never reach the mailer.
|
// 2. Invalid payloads never reach the mailer.
|
||||||
// 3. The rate limiter halts floods per fingerprint (falling back to IP).
|
// 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.
|
// sendEmail and the limiter are mocked so the test sends no real mail.
|
||||||
|
|
||||||
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
||||||
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
||||||
|
/** Every rate-limit key the route derived, in order. */
|
||||||
|
const rateKeys: string[] = [];
|
||||||
|
|
||||||
mock.module("~/server/email", () => ({
|
mock.module("~/server/email", () => ({
|
||||||
default: async (to: string, subject: string, html: string) => {
|
default: async (to: string, subject: string, html: string) => {
|
||||||
@@ -18,10 +21,16 @@ mock.module("~/server/email", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("~/server/bug-report-rate-limit", () => ({
|
mock.module("~/server/bug-report-rate-limit", () => ({
|
||||||
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true }
|
takeBugReportToken: (key: string) => {
|
||||||
|
rateKeys.push(key);
|
||||||
|
return tokenAnswers.shift() ?? { allowed: true };
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { POST } = await import("./report");
|
const { POST } = await import("./report");
|
||||||
|
const { betaRequestPayload } = await import(
|
||||||
|
"~/components/nook/betaRequest"
|
||||||
|
);
|
||||||
|
|
||||||
/** A minimal APIEvent double: JSON body plus optional headers. */
|
/** A minimal APIEvent double: JSON body plus optional headers. */
|
||||||
function request(body: unknown, headers: Record<string, string> = {}) {
|
function request(body: unknown, headers: Record<string, string> = {}) {
|
||||||
@@ -68,6 +77,7 @@ describe("POST /api/the-nook/report", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sentEmails.length = 0;
|
sentEmails.length = 0;
|
||||||
tokenAnswers.length = 0;
|
tokenAnswers.length = 0;
|
||||||
|
rateKeys.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emails the owner a report containing the snapshot, escaped", async () => {
|
it("emails the owner a report containing the snapshot, escaped", async () => {
|
||||||
@@ -129,9 +139,61 @@ describe("POST /api/the-nook/report", () => {
|
|||||||
|
|
||||||
it("still succeeds when no fingerprint header exists", async () => {
|
it("still succeeds when no fingerprint header exists", async () => {
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" })
|
request(validPayload, {
|
||||||
|
"cf-connecting-ip": "203.0.113.9",
|
||||||
|
"x-forwarded-for": "203.0.113.9, 10.0.0.1"
|
||||||
|
})
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(sentEmails).toHaveLength(1);
|
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>");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import sendEmail from "~/server/email";
|
import sendEmail from "~/server/email";
|
||||||
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
||||||
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
||||||
|
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||||
import { json, error } from "./_lib";
|
import { json, error } from "./_lib";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,10 +104,9 @@ export async function POST(event: APIEvent): Promise<Response> {
|
|||||||
)
|
)
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const forwarded = event.request.headers.get("x-forwarded-for");
|
// FRE-371: the IP fallback must be the spoof-resistant read — a rotating
|
||||||
const rateKey =
|
// leftmost x-forwarded-for used to mint unlimited report quota.
|
||||||
fingerprint ||
|
const rateKey = fingerprint || resolveTrustedClientIp(event);
|
||||||
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
|
|
||||||
const token = takeBugReportToken(rateKey);
|
const token = takeBugReportToken(rateKey);
|
||||||
if (!token.allowed) {
|
if (!token.allowed) {
|
||||||
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ 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, emailLicenseKey } 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";
|
||||||
|
|
||||||
@@ -92,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,6 +126,8 @@ function MainContactPage() {
|
|||||||
const viewer = () => searchParams.viewer ?? "default";
|
const viewer = () => searchParams.viewer ?? "default";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<ContactForm
|
<ContactForm
|
||||||
subline={
|
subline={
|
||||||
<Show when={viewer() !== "lineage"}>
|
<Show when={viewer() !== "lineage"}>
|
||||||
@@ -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"
|
||||||
|
|||||||
23
src/routes/faq.tsx
Normal file
23
src/routes/faq.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Switch, Match } from "solid-js";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import NotFound from "./[...404]";
|
||||||
|
import NookFaqPage from "./nook/faq";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-aware FAQ route — `/faq`.
|
||||||
|
*
|
||||||
|
* The FAQ is a Nook-subdomain surface; all other sites fall back to 404.
|
||||||
|
* Mirrors the dispatch pattern in `src/routes/index.tsx` and
|
||||||
|
* `src/routes/checkout.tsx` — production does not apply the vercel.json
|
||||||
|
* host rewrites (SSR sees `/faq`, never `/nook/faq`).
|
||||||
|
*/
|
||||||
|
export default function FaqPage() {
|
||||||
|
const site = useSite();
|
||||||
|
return (
|
||||||
|
<Switch fallback={<NotFound />}>
|
||||||
|
<Match when={site().id === "nook"}>
|
||||||
|
<NookFaqPage />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
export default function GazeContactPage() {
|
export default function GazeContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
@@ -48,6 +49,7 @@ export default function GazeDownloadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Download Gaze"
|
title="Download Gaze"
|
||||||
description="Download Gaze for macOS — menu bar app for eye and posture health."
|
description="Download Gaze for macOS — menu bar app for eye and posture health."
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSignal, For } from "solid-js";
|
import { createSignal, For } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
@@ -60,6 +61,7 @@ export default function GazeLanding() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Home"
|
title="Home"
|
||||||
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."
|
description="Gaze is a macOS menu bar app for eye and posture health — blink reminders, 20-20-20 breaks, posture check-ins, and customizable reminder intervals."
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function GazePrivacyPolicy() {
|
export default function GazePrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for Gaze, a macOS eye health reminder app."
|
description="Privacy policy for Gaze, a macOS eye health reminder app."
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Switch, Match, type JSX } from "solid-js";
|
import { Switch, Match, type JSX } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import { DarkModeToggle } from "~/components/DarkModeToggle";
|
import { DarkModeToggle } from "~/components/DarkModeToggle";
|
||||||
import { Typewriter } from "~/components/Typewriter";
|
import { Typewriter } from "~/components/Typewriter";
|
||||||
import { useSite } from "~/context/SiteContext";
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import { buildSubdomainUrl } from "~/lib/site-context";
|
||||||
import NessaLanding from "./nessa";
|
import NessaLanding from "./nessa";
|
||||||
import LineageLanding from "./lineage";
|
import LineageLanding from "./lineage";
|
||||||
import GazeLanding from "./gaze";
|
import GazeLanding from "./gaze";
|
||||||
@@ -56,6 +58,7 @@ export default function Home(): JSX.Element {
|
|||||||
function MainHome(): JSX.Element {
|
function MainHome(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Home"
|
title="Home"
|
||||||
description="Michael Freno - Software Engineer based in Brooklyn, NY"
|
description="Michael Freno - Software Engineer based in Brooklyn, NY"
|
||||||
@@ -101,6 +104,32 @@ function MainHome(): JSX.Element {
|
|||||||
</Typewriter>
|
</Typewriter>
|
||||||
<div class="pt-8 text-center">
|
<div class="pt-8 text-center">
|
||||||
<div class="pb-4">Some of my recent projects:</div>
|
<div class="pb-4">Some of my recent projects:</div>
|
||||||
|
{/* The Nook */}
|
||||||
|
<div class="border-surface0 mb-2 flex w-full flex-col gap-2 rounded-md border-2 p-4 text-center">
|
||||||
|
<div>My macOS notch utility:</div>
|
||||||
|
<a
|
||||||
|
href={buildSubdomainUrl("nook")}
|
||||||
|
class="text-blue hover-underline-animation mx-auto w-fit"
|
||||||
|
>
|
||||||
|
The Nook
|
||||||
|
</a>
|
||||||
|
<div class="mx-auto w-full max-w-4xl overflow-hidden rounded-lg">
|
||||||
|
<video
|
||||||
|
src="/nook/demo-expansion.mp4"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="pt-2 text-left text-sm">
|
||||||
|
A native macOS island that lives in the notch: orchestration for
|
||||||
|
a fleet of coding agents, fan curve control, thermal gauges,
|
||||||
|
calendar and camera modules. One-time purchase - no
|
||||||
|
subscription, ever.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex flex-col items-center gap-2 xl:flex-row xl:items-start xl:justify-center">
|
<div class="flex flex-col items-center gap-2 xl:flex-row xl:items-start xl:justify-center">
|
||||||
{/* FlexLöve */}
|
{/* FlexLöve */}
|
||||||
<div class="border-surface0 flex w-full flex-col rounded-md border-2 p-4 text-center">
|
<div class="border-surface0 flex w-full flex-col rounded-md border-2 p-4 text-center">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
export default function InputHaloContactPage() {
|
export default function InputHaloContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
@@ -51,6 +52,7 @@ export default function InputHaloDownloadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Download InputHalo"
|
title="Download InputHalo"
|
||||||
description="Download InputHalo for macOS — menu bar app for keyboard, mouse, and scroll visualization."
|
description="Download InputHalo for macOS — menu bar app for keyboard, mouse, and scroll visualization."
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { For, createSignal } from "solid-js";
|
import { For, createSignal } from "solid-js";
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
@@ -98,6 +99,7 @@ export default function InputHaloLanding() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Home"
|
title="Home"
|
||||||
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."
|
description="A polished macOS menu bar app that visualizes keyboard presses, mouse clicks, cursor halos, and scroll events on screen — for streamers, presenters, and developers."
|
||||||
|
|||||||
@@ -23,11 +23,13 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function InputHaloPrivacyPolicy() {
|
export default function InputHaloPrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for InputHalo, a macOS menu bar productivity app."
|
description="Privacy policy for InputHalo, a macOS menu bar productivity app."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import { LineageContactQuestions } from "../contact";
|
import { LineageContactQuestions } from "../contact";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ import { LineageContactQuestions } from "../contact";
|
|||||||
export default function LineageContactPage() {
|
export default function LineageContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<ContactForm>
|
<ContactForm>
|
||||||
<LineageContactQuestions />
|
<LineageContactQuestions />
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
* the form posts to the correct tRPC mutation (Lineage-branded email).
|
* the form posts to the correct tRPC mutation (Lineage-branded email).
|
||||||
*/
|
*/
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DeletionForm from "~/components/DeletionForm";
|
import DeletionForm from "~/components/DeletionForm";
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
export default function LineageDeletionPage() {
|
export default function LineageDeletionPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<div class="pt-20">
|
<div class="pt-20">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { createSignal, onMount, onCleanup } from "solid-js";
|
import { createSignal, onMount, onCleanup } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
import DownloadOnAppStore from "~/components/icons/DownloadOnAppStore";
|
||||||
import Button from "~/components/ui/Button";
|
import Button from "~/components/ui/Button";
|
||||||
@@ -71,6 +72,7 @@ export default function LineageDownloadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
|
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import SimpleParallax from "~/components/SimpleParallax";
|
import SimpleParallax from "~/components/SimpleParallax";
|
||||||
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
import DownloadOnAppStoreDark from "~/components/icons/DownloadOnAppStoreDark";
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
export default function LineageLandingPage() {
|
export default function LineageLandingPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<SimpleParallax>
|
<SimpleParallax>
|
||||||
|
|||||||
@@ -17,11 +17,13 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function LineagePrivacyPolicy() {
|
export default function LineagePrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for Life and Lineage mobile game, outlining data collection, usage, and user rights."
|
description="Privacy policy for Life and Lineage mobile game, outlining data collection, usage, and user rights."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ContactForm } from "~/components/ContactForm";
|
import { ContactForm } from "~/components/ContactForm";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,7 @@ import SubdomainHeader from "~/components/SubdomainHeader";
|
|||||||
export default function NessaContactPage() {
|
export default function NessaContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
|
* Acceptance: `nessa.localhost:3000/deletion` renders the deletion form.
|
||||||
*/
|
*/
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import DeletionForm from "~/components/DeletionForm";
|
import DeletionForm from "~/components/DeletionForm";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
export default function NessaDeletionPage() {
|
export default function NessaDeletionPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
<PageHead title={PAGE_META.title} description={PAGE_META.description} />
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
<div class="pt-20">
|
<div class="pt-20">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
import { For, Show } from "solid-js";
|
import { For, Show } from "solid-js";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
import { useSite } from "~/context/SiteContext";
|
import { useSite } from "~/context/SiteContext";
|
||||||
@@ -59,6 +60,7 @@ export default function NessaLanding() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead {...NESSA_LANDING_META} />
|
<PageHead {...NESSA_LANDING_META} />
|
||||||
|
|
||||||
<SubdomainHeader />
|
<SubdomainHeader />
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ describe("Nessa landing page — PageHead metadata", () => {
|
|||||||
expect(meta.canonical).toBe("https://nessa.freno.me/");
|
expect(meta.canonical).toBe("https://nessa.freno.me/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ogImage falls back to the nessa site default", () => {
|
it("ogImage falls back to the nessa site default, resolved absolute", () => {
|
||||||
const meta = resolvePageHeadMeta(
|
const meta = resolvePageHeadMeta(
|
||||||
NESSA_LANDING_META,
|
NESSA_LANDING_META,
|
||||||
SITE_CONFIG.nessa,
|
SITE_CONFIG.nessa,
|
||||||
"/"
|
"/"
|
||||||
);
|
);
|
||||||
expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage);
|
expect(meta.ogImage).toBe("https://nessa.freno.me/nessa/og-default.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ogTitle uses the explicit marketing copy, not the bare title", () => {
|
it("ogTitle uses the explicit marketing copy, not the bare title", () => {
|
||||||
|
|||||||
@@ -4,17 +4,16 @@
|
|||||||
* Net-new privacy policy for the Nessa subdomain. Modeled on the Life and
|
* Net-new privacy policy for the Nessa subdomain. Modeled on the Life and
|
||||||
* Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices:
|
* Lineage policy (the template for products with user accounts) but scoped to Nessa's real data practices:
|
||||||
*
|
*
|
||||||
* - Authentication: user accounts are managed by Clerk
|
* - Authentication: user accounts are managed by Clerk (the Nessa API server
|
||||||
* (`src/server/nessa-auth.ts` verifies Clerk session JWTs via the Clerk
|
* verifies Clerk session JWTs). Nessa itself does not store passwords —
|
||||||
* Backend JWKS endpoint). Nessa itself does not store passwords — Clerk is
|
* Clerk is the identity provider.
|
||||||
* the identity provider.
|
|
||||||
* - Community content: clubs, club challenges, club posts, post likes, and
|
* - Community content: clubs, club challenges, club posts, post likes, and
|
||||||
* post comments (see `src/server/api/routers/nessa-community.ts`,
|
* post comments (`clubMemberships`, `clubChallenges`,
|
||||||
* `clubMemberships`, `clubChallenges`, `clubChallengeParticipations`,
|
* `clubChallengeParticipations`, `clubPosts`, `clubPostLikes`,
|
||||||
* `clubPosts`, `clubPostLikes`, `clubPostComments`).
|
* `clubPostComments`).
|
||||||
* - Storage: the Nessa data lives in a dedicated Turso (libSQL) database
|
* - Storage: the Nessa data lives in a dedicated Turso (libSQL) database,
|
||||||
* (`NessaConnectionFactory` in `src/server/db-connections.ts`), separate
|
* served by the Nessa API (`nessa-api.freno.me`, the Nessa repo's
|
||||||
* from the freno.me main DB and the Lineage DB.
|
* `server/`), separate from the freno.me main DB and the Lineage DB.
|
||||||
*
|
*
|
||||||
* PageHead is site-aware: only the base title is supplied; the
|
* PageHead is site-aware: only the base title is supplied; the
|
||||||
* ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image
|
* ` | Nessa` suffix, `https://nessa.freno.me/privacy` canonical, and OG image
|
||||||
@@ -29,11 +28,13 @@
|
|||||||
*/
|
*/
|
||||||
import { A } from "@solidjs/router";
|
import { A } from "@solidjs/router";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
export default function NessaPrivacyPolicy() {
|
export default function NessaPrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
<PageHead
|
<PageHead
|
||||||
title="Privacy Policy"
|
title="Privacy Policy"
|
||||||
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features."
|
description="Privacy policy for Nessa, a community platform for clubs, challenges, and social features."
|
||||||
|
|||||||
446
src/routes/nessa/terms.tsx
Normal file
446
src/routes/nessa/terms.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* Nessa terms of use — `nessa.freno.me/terms`.
|
||||||
|
*
|
||||||
|
* The document behind the `Terms of Service` row in the Nessa app
|
||||||
|
* (`Nessa/Models/AppSettings.swift`, Settings › Legal). Before this page the
|
||||||
|
* row had no Nessa-owned document to resolve to: the app linked another
|
||||||
|
* product's terms, and `nessa.freno.me/terms` was a 404 (FRE-194).
|
||||||
|
*
|
||||||
|
* Nessa sells an auto-renewing subscription through the App Store, so App
|
||||||
|
* Review 3.1.2 requires the app to link a privacy policy **and** terms of
|
||||||
|
* use. Section 5 (subscriptions) and section 6 (Apple acknowledgements) are
|
||||||
|
* the clauses that requirement is read against, so keep them when editing.
|
||||||
|
*
|
||||||
|
* House voice follows `privacy.tsx`: "we" / "Nessa", no legal entity name,
|
||||||
|
* and no governing-law clause — the Nessa pages have never stated either, and
|
||||||
|
* naming an entity or a jurisdiction is a decision to make deliberately, not
|
||||||
|
* to introduce as a side effect of a link fix.
|
||||||
|
*
|
||||||
|
* PageHead is site-aware: only the base title is supplied; the ` | Nessa`
|
||||||
|
* suffix, the `https://nessa.freno.me/terms` canonical and the OG image are
|
||||||
|
* derived automatically. Internal links use public subdomain-relative paths
|
||||||
|
* (`/privacy`, `/deletion`, `/contact`) per nav-config.ts and
|
||||||
|
* page-head-meta.ts.
|
||||||
|
*/
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
|
export default function NessaTermsOfUse() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
|
<PageHead
|
||||||
|
title="Terms of Use"
|
||||||
|
description="Terms of use for Nessa, a fitness app for tracking workouts and training with clubs, challenges, and friends."
|
||||||
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
|
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||||
|
<div class="py-4 text-xl">Nessa's Terms of Use</div>
|
||||||
|
<div class="py-2">Last Updated: November 7, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
Welcome to Nessa ('We', 'Us', 'Our').
|
||||||
|
These terms of use govern your access to and use of the Nessa app and
|
||||||
|
its related services. By using Nessa you agree to these terms. Our{" "}
|
||||||
|
<A href="/privacy" class="text-blue hover-underline-animation">
|
||||||
|
privacy policy
|
||||||
|
</A>{" "}
|
||||||
|
explains how we handle your data, and it forms part of these terms.
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">1.</span> Acceptance of These Terms
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Agreement:</div> By creating an account,
|
||||||
|
downloading, or otherwise using Nessa you agree to these terms.
|
||||||
|
If you do not agree to them, do not use Nessa.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Eligibility:</div> You must be at least
|
||||||
|
the age of digital consent in your country to use Nessa. If you
|
||||||
|
are using Nessa with a parent's or guardian's
|
||||||
|
account, they are responsible for your compliance with these
|
||||||
|
terms.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Where these terms apply:</div> These
|
||||||
|
terms are between you and us. Where you obtained Nessa through
|
||||||
|
a third party such as the Apple App Store, that
|
||||||
|
party's own terms apply to your relationship with them as
|
||||||
|
well.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">2.</span> The Service
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) What Nessa is:</div> Nessa is a fitness
|
||||||
|
application for iOS, with an Apple Watch companion, that lets
|
||||||
|
you record workouts and routes, review your training history,
|
||||||
|
and take part in community features — clubs, club challenges,
|
||||||
|
posts, comments, and leaderboards. Some features are free and
|
||||||
|
some require a paid subscription (see section 5).
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Changes and availability:</div> We are
|
||||||
|
continually improving Nessa, so features may change, move
|
||||||
|
between tiers, or be withdrawn. We do not guarantee that Nessa
|
||||||
|
will be available at all times, and we may suspend or discontinue
|
||||||
|
part or all of the service. Where a change materially affects a
|
||||||
|
paid feature you have subscribed to, we will tell you on the
|
||||||
|
subscription management page or by contact.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Not medical advice:</div> Nessa is not a
|
||||||
|
medical device and is not intended to diagnose, treat, cure, or
|
||||||
|
prevent any condition. Workout data, heart-rate and other
|
||||||
|
health readings, training plans, and estimates you see in Nessa
|
||||||
|
are for general information and motivation only. Consult a
|
||||||
|
qualified professional before starting or changing a training
|
||||||
|
programme, especially if you have a health condition. Never
|
||||||
|
ignore professional advice, or delay seeking it, because of
|
||||||
|
something you read in Nessa.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Your surroundings:</div> You are
|
||||||
|
responsible for your own safety while using Nessa, including
|
||||||
|
when recording an activity. Follow local laws and do not use
|
||||||
|
Nessa in a way that distracts you from driving or other
|
||||||
|
activities that require your full attention.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">3.</span> Your Account
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Sign-in:</div> Nessa accounts are
|
||||||
|
authenticated through Clerk, our identity provider. You are
|
||||||
|
responsible for keeping the credentials you use to sign in
|
||||||
|
secure, and you are responsible for the activity that happens
|
||||||
|
under your account.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Notify us:</div> Tell us promptly, using
|
||||||
|
the contact details in section 11, if you believe someone else
|
||||||
|
has used or may use your account without your permission.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) One account:</div> Keep to one account,
|
||||||
|
and give us accurate information so we can keep your training
|
||||||
|
history and your place in the community correct. Do not create
|
||||||
|
accounts to evade a moderation decision or to gain a fair
|
||||||
|
advantage in challenges or leaderboards.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">4.</span> Community Content and Acceptable
|
||||||
|
Use
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) You keep your content:</div> You own what
|
||||||
|
you post. By posting to a club, challenge, or other shared area
|
||||||
|
of Nessa you grant us a limited, non-exclusive licence to store,
|
||||||
|
reproduce, and display that content inside Nessa so that the
|
||||||
|
feature you used can work — for example, showing your post to
|
||||||
|
the members of your club. That licence ends when your content is
|
||||||
|
removed from our systems. We handle your content as described in
|
||||||
|
our{" "}
|
||||||
|
<A href="/privacy" class="text-blue hover-underline-animation">
|
||||||
|
privacy policy
|
||||||
|
</A>
|
||||||
|
; we do not sell it.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) What you are responsible for:</div> You
|
||||||
|
are responsible for the content you post and the data you record
|
||||||
|
in Nessa, and for who you share it with. Use the privacy
|
||||||
|
settings on your profile and the privacy rules of the clubs you
|
||||||
|
join before sharing anything you would not want others to see.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Do not:</div> (i) post anything unlawful,
|
||||||
|
harassing, hateful, threatening, or sexually explicit involving
|
||||||
|
minors; (ii) post content you do not have the right to share, or
|
||||||
|
that infringes someone else's intellectual property;
|
||||||
|
(iii) falsify activity data, spoof a location, or otherwise
|
||||||
|
cheat a challenge, leaderboard, or competition; (iv) scrape,
|
||||||
|
bulk-download, or access Nessa through automated means we have
|
||||||
|
not authorised; (v) interfere with, overload, or probe Nessa for
|
||||||
|
vulnerabilities, or attempt to access another user's
|
||||||
|
account or data; (vi) resell, sublicense, or offer Nessa to
|
||||||
|
others as your own service.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Moderation:</div> We may remove or
|
||||||
|
restrict content, disqualify an entry from a challenge, or
|
||||||
|
suspend or close an account that breaks these terms. We are not
|
||||||
|
obliged to pre-monitor content. You can report content or a user
|
||||||
|
to us at any time using the contact details in section 11.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">5.</span> Subscriptions and Payments
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Paid tiers:</div> Nessa offers paid
|
||||||
|
subscription tiers, Nessa Plus and Nessa Pro, which unlock
|
||||||
|
features described in the app. Subscriptions are sold as
|
||||||
|
auto-renewing subscriptions through the Apple App Store and are
|
||||||
|
billed to your Apple Account.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Payment:</div> Payment is charged to your
|
||||||
|
Apple Account at confirmation of purchase. The price of your
|
||||||
|
plan is shown in the app before you buy, and it is the price on
|
||||||
|
your App Store purchase receipt.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Auto-renewal:</div> Unless you cancel at
|
||||||
|
least 24 hours before the end of the current period, your
|
||||||
|
subscription renews automatically. Your account is charged for
|
||||||
|
renewal within 24 hours before the end of the current period, at
|
||||||
|
the price of the plan you selected.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Managing your subscription:</div> You can
|
||||||
|
turn off auto-renewal, or cancel, at any time in your App Store
|
||||||
|
account settings after purchase. Changes made there take effect
|
||||||
|
at the end of the current period, and you keep access to the
|
||||||
|
paid features you have already paid for until then.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(e) Trials:</div> If we offer a free trial,
|
||||||
|
any unused portion of it is forfeited when you purchase a
|
||||||
|
subscription, and the trial converts to a paid auto-renewing
|
||||||
|
subscription unless you cancel it before it ends.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(f) Refunds:</div> Purchases made through the
|
||||||
|
App Store are refunded according to Apple's refund policy
|
||||||
|
and, where applicable law gives you a right to a refund,
|
||||||
|
according to that law. Tell us using the contact details in
|
||||||
|
section 11 and we will do what we can.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(g) Price changes:</div> We may change our
|
||||||
|
prices. A price change does not apply to a period you have
|
||||||
|
already paid for; it applies from your next renewal, and we give
|
||||||
|
notice where the law requires it.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(h) Downgrade:</div> When a subscription
|
||||||
|
lapses or is cancelled you keep the free features, and anything
|
||||||
|
you created that belongs to a paid feature (such as a custom
|
||||||
|
route) is kept but is not editable or usable until you
|
||||||
|
subscribe again.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">6.</span> Apple and Other Third Parties
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Not Apple's terms:</div> These terms
|
||||||
|
are between you and us, not with Apple Inc. Apple is not
|
||||||
|
responsible for Nessa or its content, and your use of Nessa must
|
||||||
|
comply with the Apple App Store Terms of Service.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) No Apple support:</div> Apple has no
|
||||||
|
obligation to furnish maintenance and support services for
|
||||||
|
Nessa. If Nessa fails, you may notify Apple, but Apple has no
|
||||||
|
further support obligation, and support and any warranties
|
||||||
|
relating to Nessa are ours to provide as described in these
|
||||||
|
terms.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Apple is not responsible for claims:</div>{" "}
|
||||||
|
Apple is not responsible for product liability claims,
|
||||||
|
intellectual property infringement claims, or any other claims
|
||||||
|
relating to Nessa or your possession and use of it. We, not
|
||||||
|
Apple, are responsible for addressing any such claims and for
|
||||||
|
any claims that you may have relating to Nessa.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Compliance:</div> You confirm that you
|
||||||
|
are not located in a country subject to an embargo of the United
|
||||||
|
States or listed by the United States government as a
|
||||||
|
terrorist-supporting country, and that you are not listed on any
|
||||||
|
United States government list of prohibited or restricted
|
||||||
|
parties.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(e) Third-party services:</div> Nessa depends
|
||||||
|
on third-party services — Clerk for authentication, Apple
|
||||||
|
HealthKit for health and workout data, Apple's In-App
|
||||||
|
Purchase system for subscriptions, and our own hosting — each
|
||||||
|
governed by its own terms. Where those terms affect how you may
|
||||||
|
use the feature they power, they apply alongside these terms.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">7.</span> Intellectual Property
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Our rights:</div> The Nessa software, its
|
||||||
|
design, text, icons, and branding are owned by us or our
|
||||||
|
licensors and are protected by copyright and other laws. We
|
||||||
|
grant you a personal, non-exclusive, non-transferable, revocable
|
||||||
|
licence to use Nessa for your own personal, non-commercial
|
||||||
|
purposes.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Restrictions:</div> You may not copy,
|
||||||
|
modify, translate, distribute, sell, lease, sublicense, or
|
||||||
|
create derivative works from Nessa, and you may not reverse
|
||||||
|
engineer, decompile, or attempt to obtain its source code,
|
||||||
|
except to the extent applicable law allows this despite a
|
||||||
|
restriction.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">8.</span> Disclaimers and Limitation of
|
||||||
|
Liability
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) As is:</div> Nessa is provided "as
|
||||||
|
is" and "as available" to the fullest extent
|
||||||
|
permitted by applicable law. We do not warrant that Nessa will
|
||||||
|
be uninterrupted, secure, error-free, or that recorded data will
|
||||||
|
be accurate — GPS-derived distance, pace, elevation, and
|
||||||
|
estimates can be wrong, and device and sensor limits apply.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Your data:</div> You are responsible for
|
||||||
|
backing up the data you store on your device. We keep
|
||||||
|
Nessa's service data as described in the{" "}
|
||||||
|
<A href="/privacy" class="text-blue hover-underline-animation">
|
||||||
|
privacy policy
|
||||||
|
</A>{" "}
|
||||||
|
and{" "}
|
||||||
|
<A href="/deletion" class="text-blue hover-underline-animation">
|
||||||
|
deletion page
|
||||||
|
</A>
|
||||||
|
, but no service is a substitute for your own backup.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Liability:</div> To the fullest extent
|
||||||
|
permitted by law, we are not liable for indirect, incidental,
|
||||||
|
special, consequential, or punitive damages, or for lost data,
|
||||||
|
lost profits, or loss of training records, arising out of your
|
||||||
|
use of, or inability to use, Nessa. Where a court would not
|
||||||
|
allow that exclusion, our liability for claims arising from your
|
||||||
|
use of Nessa is limited to the greater of the subscription fees
|
||||||
|
you paid us in the twelve months before the event giving rise to
|
||||||
|
the claim, or the minimum the applicable law requires. Nothing
|
||||||
|
in these terms limits liability that cannot lawfully be limited,
|
||||||
|
including for fraud or for death or personal injury caused by
|
||||||
|
our negligence.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">9.</span> Ending Your Use
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) You can leave:</div> You can stop using
|
||||||
|
Nessa at any time. You can delete your account and the content
|
||||||
|
associated with it through our{" "}
|
||||||
|
<A href="/deletion" class="text-blue hover-underline-animation">
|
||||||
|
account deletion page
|
||||||
|
</A>
|
||||||
|
. Deleting your account does not cancel a subscription that is
|
||||||
|
still in its paid period; cancel that in your App Store account
|
||||||
|
settings as described in section 5.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) We may end access:</div> We may suspend
|
||||||
|
or close your access to Nessa if you break these terms, if we
|
||||||
|
are required to by law, or if we reasonably believe your account
|
||||||
|
has been compromised. Where we can, we will tell you why.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) On termination:</div> Your licence to use
|
||||||
|
Nessa ends, and sections 4(a), 6, 7, 8, 10, and 11 continue to
|
||||||
|
apply.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">10.</span> Changes to These Terms
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Updates:</div> We may update these terms
|
||||||
|
as Nessa changes. The current version is always the one at{" "}
|
||||||
|
<A href="/terms" class="text-blue hover-underline-animation">
|
||||||
|
nessa.freno.me/terms
|
||||||
|
</A>
|
||||||
|
, and the "Last Updated" date at the top of this page
|
||||||
|
shows when it last changed.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Continued use:</div> If you keep using
|
||||||
|
Nessa after a change takes effect, you accept the revised terms.
|
||||||
|
For changes that materially reduce your rights or your paid
|
||||||
|
features, we will give notice in the app or by contact before
|
||||||
|
they take effect.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">11.</span> Contact Us
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
<div class="-ml-6">(a) Reaching Out:</div> If you have any
|
||||||
|
questions or comments about these terms, want to report content,
|
||||||
|
or need help with a subscription, you can contact us{" "}
|
||||||
|
<A href="/contact" class="text-blue hover-underline-animation">
|
||||||
|
here
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/routes/nook/agents.test.ts
Normal file
69
src/routes/nook/agents.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Regression test for the /agents roster and its coverage count.
|
||||||
|
*
|
||||||
|
* The page mirrors the app's registry, `AgentSpec.all` in
|
||||||
|
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
|
||||||
|
* the registry in 0.10.0 the page kept twelve entries and kept saying
|
||||||
|
* "twelve", so a shipped agent was missing from the one page that lists
|
||||||
|
* them. The registry lives in another repo, so the expected roster is
|
||||||
|
* restated here; when an agent is added there, add it here and on the page.
|
||||||
|
*
|
||||||
|
* FRE-997 restored the count. The page said thirteen while the registry held
|
||||||
|
* eighteen, so the count was dropped rather than corrected (FRE-359 verdict B);
|
||||||
|
* now that all eighteen rows are on the page, the roster check is an equality
|
||||||
|
* check again and the page states eighteen. A subset check would let a shipped
|
||||||
|
* agent go missing behind a passing suite, which is the NOOK-336 failure.
|
||||||
|
*
|
||||||
|
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
|
||||||
|
|
||||||
|
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||||
|
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||||
|
|
||||||
|
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
|
||||||
|
const REGISTRY = [
|
||||||
|
"Claude Code",
|
||||||
|
"Codex",
|
||||||
|
"OpenCode",
|
||||||
|
"Pi",
|
||||||
|
"Oh My Pi",
|
||||||
|
"Muse",
|
||||||
|
"Hermes",
|
||||||
|
"OpenClaw",
|
||||||
|
"Z.ai (ZCode)",
|
||||||
|
"Gemini CLI",
|
||||||
|
"Cline",
|
||||||
|
"Qwen Code",
|
||||||
|
"DeepSeek",
|
||||||
|
"Kimi",
|
||||||
|
"GitHub Copilot CLI",
|
||||||
|
"Crush",
|
||||||
|
"Cursor",
|
||||||
|
"Amp"
|
||||||
|
];
|
||||||
|
|
||||||
|
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
|
||||||
|
|
||||||
|
describe("Nook /agents roster", () => {
|
||||||
|
it("lists every registry agent, once, in registry order", () => {
|
||||||
|
expect(entries).toEqual(REGISTRY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("states the registry's size", () => {
|
||||||
|
expect(entries.length).toBe(18);
|
||||||
|
expect(COPY).toContain("all eighteen");
|
||||||
|
expect(COPY).toContain("The 18 agents");
|
||||||
|
expect(COPY).toContain("eighteen coding agents");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries no stale count", () => {
|
||||||
|
expect(COPY.toLowerCase()).not.toContain("thirteen");
|
||||||
|
expect(COPY).not.toMatch(/\b13 agents\b/);
|
||||||
|
expect(COPY).not.toContain("all thirteen");
|
||||||
|
});
|
||||||
|
});
|
||||||
431
src/routes/nook/agents.tsx
Normal file
431
src/routes/nook/agents.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
/**
|
||||||
|
* `/agents` on the Nook subdomain — all eighteen agents in the registry and
|
||||||
|
* each row's event coverage.
|
||||||
|
*
|
||||||
|
* The registry below mirrors `AgentSpec.all` in the app
|
||||||
|
* (~/Code/the-nook/Sources/NookCore/Agents/AgentRegistry.swift) and each
|
||||||
|
* agent's installed hook surface (hook/setup.go) or plugin event subscription
|
||||||
|
* (plugins/*), normalized onto the shared event vocabulary
|
||||||
|
* (~/Code/the-nook/docs/agent-interface.md). Copy follows
|
||||||
|
* marketing/positioning.md: category nouns, honest scoping, no unverifiable
|
||||||
|
* claims.
|
||||||
|
*/
|
||||||
|
import { For } from "solid-js";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
|
interface AgentEntry {
|
||||||
|
name: string;
|
||||||
|
/** How The Nook connects to this agent. */
|
||||||
|
wiring: string;
|
||||||
|
/** Events the installed hook/plugin reports, on the shared vocabulary. */
|
||||||
|
events: string[];
|
||||||
|
/** What the agent can be asked to do from a Nook card. */
|
||||||
|
gating: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AGENTS: AgentEntry[] = [
|
||||||
|
{
|
||||||
|
name: "Claude Code",
|
||||||
|
wiring: "Hook entries in its settings, calling the shared hook binary",
|
||||||
|
events: [
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"Stop",
|
||||||
|
"StopFailure",
|
||||||
|
"SessionError",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"Notification",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"PermissionDenied",
|
||||||
|
"PreCompact"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Codex",
|
||||||
|
wiring: "Hook entries in its config, with its hooks feature enabled",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"SessionError"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Full — command/file approvals and user-input questions can be answered from a card"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenCode",
|
||||||
|
wiring: "In-process plugin on its server bus",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"MessageUpdate",
|
||||||
|
"PermissionRequest",
|
||||||
|
"QuestionAsked"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Full — the plugin answers OpenCode's own permission API when you tap Allow or Deny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pi",
|
||||||
|
wiring: "In-process extension registered in its settings",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"AgentStart",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"MessageUpdate",
|
||||||
|
"QuestionAsked",
|
||||||
|
"QuestionResolved"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Full when you force verification — Pi alone never asks natively, so the gate engages on the approval tool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Oh My Pi",
|
||||||
|
wiring: "In-process extension package",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"AgentStart",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PermissionResolved",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"MessageUpdate",
|
||||||
|
"QuestionAsked",
|
||||||
|
"QuestionResolved"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Full — honors the agent's own approval tiers, per-tool policies included"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Muse",
|
||||||
|
wiring:
|
||||||
|
"Plugin installed, approved and enabled through Muse's own plugins CLI; each hook calls the shared hook binary. This Mac only — remote hosts skip it",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"StopFailure",
|
||||||
|
"Notification",
|
||||||
|
"PreCompact",
|
||||||
|
"PostCompact",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Full — permission prompts can be allowed or denied from a card, and every tool call under Verify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hermes",
|
||||||
|
wiring:
|
||||||
|
"Shell-hook entries in the agent's config, consent-gated by allowlist",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"Stop",
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"QuestionAsked"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — every event appears as a card; the agent keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenClaw",
|
||||||
|
wiring: "Hook handler package in its hooks directory",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"PreCompact",
|
||||||
|
"SessionEnd"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — strict fire-and-forget; the agent surface has no tool or permission events"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Z.ai (ZCode)",
|
||||||
|
wiring: "Hook entries in its CLI config",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gemini CLI",
|
||||||
|
wiring: "Hook entries in its settings",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"AgentStart",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"Notification"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — every event appears as a card; the agent keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cline",
|
||||||
|
wiring: "One hook file per event in its hooks directory",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"PreCompact",
|
||||||
|
"Notification"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — every event appears as a card; the agent keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Qwen Code",
|
||||||
|
wiring:
|
||||||
|
"Hook entries in its settings (same surface as the Claude-style schema)",
|
||||||
|
events: [
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"Stop",
|
||||||
|
"StopFailure",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"Notification",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"PermissionDenied",
|
||||||
|
"PreCompact"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Full — tool calls and permission prompts can be allowed or denied from a card"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DeepSeek",
|
||||||
|
wiring: "A standalone hooks file its bridge is pointed at",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kimi",
|
||||||
|
wiring: "Hook rules in its TOML config, one per event",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"AgentStart",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PermissionResolved",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact",
|
||||||
|
"Notification"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — every event appears as a card; its PreToolUse fires before its own permission check, so the agent keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GitHub Copilot CLI",
|
||||||
|
wiring: "One hook file of ours in its hooks directory",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PermissionRequest",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"ErrorOccurred",
|
||||||
|
"PreCompact",
|
||||||
|
"Notification"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — every event appears as a card; its permission request runs before its own policy check, so the agent keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Crush",
|
||||||
|
wiring:
|
||||||
|
"One entry in its JSONC config, the only event it documents",
|
||||||
|
events: ["PreToolUse"],
|
||||||
|
gating:
|
||||||
|
"Observation — tool calls appear as cards; its own permission prompts decide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cursor",
|
||||||
|
wiring:
|
||||||
|
"Hook entries in its hooks.json, merged with your own. No permission hook, on purpose",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"Stop",
|
||||||
|
"SessionError",
|
||||||
|
"AfterAgentResponse",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — a permission hook that stays silent blocks the action here, so Cursor keeps its own prompting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Amp",
|
||||||
|
wiring: "In-process extension package",
|
||||||
|
events: [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"Stop",
|
||||||
|
"SessionError"
|
||||||
|
],
|
||||||
|
gating:
|
||||||
|
"Observation — its plugin API has no permission-wait event, so the agent keeps its own prompting"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const GATING_BADGE: Record<string, { label: string; class: string }> = {
|
||||||
|
full: {
|
||||||
|
label: "Allow / Deny from a card",
|
||||||
|
class: "text-green border-green/40 bg-green/10"
|
||||||
|
},
|
||||||
|
observation: {
|
||||||
|
label: "Observe",
|
||||||
|
class: "text-blue border-blue/40 bg-blue/10"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function gatingKey(entry: AgentEntry): "full" | "observation" {
|
||||||
|
return entry.gating.startsWith("Full") ? "full" : "observation";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NookAgentsPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
|
<PageHead
|
||||||
|
title="Supported agents"
|
||||||
|
description="Every coding agent The Nook supports — all eighteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
|
||||||
|
ogTitle="The 18 agents The Nook supports, and what each hook sees"
|
||||||
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
|
<main class="min-h-screen px-[8vw] py-[8vh]">
|
||||||
|
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||||
|
Supported agents
|
||||||
|
</h1>
|
||||||
|
<p class="text-subtext0 mb-2 max-w-2xl text-lg">
|
||||||
|
The Nook watches eighteen coding agents in one place — your menu bar and
|
||||||
|
notch show every session that is running, thinking, or blocked, and a
|
||||||
|
card answers permission prompts without leaving your keyboard.
|
||||||
|
</p>
|
||||||
|
<p class="text-subtext1 mb-10 max-w-2xl text-sm">
|
||||||
|
Each agent reports its own set of events. "Allow / Deny from a card"
|
||||||
|
means the agent hands the decision to The Nook while it waits;
|
||||||
|
"Observe" means every event still appears live, but the agent keeps
|
||||||
|
its own prompting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<For each={AGENTS}>
|
||||||
|
{(agent) => (
|
||||||
|
<article class="border-overlay0 bg-surface0/40 rounded-2xl border p-5">
|
||||||
|
<div class="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<h2 class="text-text text-lg font-semibold">{agent.name}</h2>
|
||||||
|
<span
|
||||||
|
class={`shrink-0 rounded-full border px-3 py-1 text-xs font-semibold ${GATING_BADGE[gatingKey(agent)].class}`}
|
||||||
|
>
|
||||||
|
{GATING_BADGE[gatingKey(agent)].label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext0 mb-3 text-sm">{agent.wiring}</p>
|
||||||
|
<p class="text-subtext1 mb-1 text-xs font-semibold tracking-wide uppercase">
|
||||||
|
Events reported
|
||||||
|
</p>
|
||||||
|
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||||
|
<For each={agent.events}>
|
||||||
|
{(event) => (
|
||||||
|
<code class="border-overlay0 bg-base text-subtext0 rounded-md border px-2 py-0.5 font-mono text-xs">
|
||||||
|
{event}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<p class="text-subtext0 text-sm">{agent.gating}</p>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-subtext1 mt-10 max-w-2xl text-sm">
|
||||||
|
Running something else? A custom agent can wire itself in with the
|
||||||
|
same one-line contract the built-in integrations use.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/routes/nook/changelog-archive.ts
Normal file
80
src/routes/nook/changelog-archive.ts
Normal file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user