forked from Mike/freno-dev
Compare commits
1 Commits
FRE-1670-g
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89f56fc78a |
@@ -22,3 +22,12 @@ a human reviews and moves it to `approved/` → the human publishes from `approv
|
||||
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,8 +1,13 @@
|
||||
# Remote SSH copy — historical unqualified wording
|
||||
# Remote SSH copy — HELD, do not publish
|
||||
|
||||
[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring the remote-agent cards and corrected the old blanket restriction. One-click setup works for **Tailscale and authorized known hosts**. The current page describes that scope; this file preserves the old unqualified wording for reference, not for verbatim reuse.
|
||||
|
||||
[gpt-6.1-sol] The earlier FRE-323 removal treated SSH authorization needed by a new host as though it prevented one-click setup for hosts already authorized. Current evidence is `RemoteHostCatalog.discover`, `RemoteHostsController.setUp`, `RemoteMirror.apply`, and the existing `RemoteHostsControllerLiveTests.setUpLeavesItLive` test. No new live-host test run is claimed by this website change.
|
||||
**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
|
||||
|
||||
@@ -57,8 +62,18 @@ Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
|
||||
One-click SSH setup
|
||||
```
|
||||
|
||||
## Current restoration
|
||||
## What replaced it
|
||||
|
||||
[gpt-6.1-sol] NOOK-1038 restores the collage's Remote tile, host-labelled session examples, and the detailed setup section. The collage says: “One-click setup for Tailscale and authorized known hosts.” The detailed section shows choosing a discovered host, clicking Set up, installation progress, and Live status.
|
||||
Nothing. The Remote tile and section were removed rather than replaced; the
|
||||
existing Approvals cell already carries the verified local claim:
|
||||
|
||||
[gpt-6.1-sol] The historical “Any SSH box” claim, universal agent-coverage bullet, and unqualified setup chip above are not restored. Discovery and an existing authorization path are not the same as granting access to a new machine. This does not limit or deny the working one-click flow for authorized hosts.
|
||||
> 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.
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
# Remote setup marketing decision
|
||||
# Why the remote claim is parked
|
||||
|
||||
[gpt-6.1-sol] **NOOK-1038 supersedes the blanket hold.** Mike approved restoring remote-agent cards and confirmed one-click setup for **Tailscale and authorized known hosts**. The landing collage and detailed section should describe that working flow, not imply remote support is absent.
|
||||
The site was selling a setup path a buyer does not get.
|
||||
|
||||
## Why it was removed
|
||||
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.
|
||||
|
||||
[gpt-6.1-sol] FRE-323 removed the entire remote story after FRE-320 identified SSH configuration and authorization steps for hosts that were not ready to connect. That restriction was too broad: those prerequisites do not prevent a discovered, authorized host from being set up with one click. The historical unqualified copy remains in `copy.md` so the change in scope is explicit.
|
||||
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.
|
||||
|
||||
## Current evidence and scope
|
||||
## What has to be true before it returns
|
||||
|
||||
[gpt-6.1-sol] `RemoteHostCatalog.discover` reads SSH config and Tailscale peers. `RemoteHostsController.setUp` binds the bridge and calls `RemoteMirror.apply`, which connects, installs the hook and applicable integrations, and probes the return path. The existing `RemoteHostsControllerLiveTests.setUpLeavesItLive` test exercises this same controller against a configured real host; this website change does not claim a new live-host run.
|
||||
| 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 |
|
||||
|
||||
[gpt-6.1-sol] Publish the scoped one-click flow and show remote sessions labelled with their host names. Do not replace it with a “manual setup only” disclaimer. Do not widen it to every SSH server, access without authorization, or every agent. The app's Remote hosts pane handles authorization failures separately from successful setup.
|
||||
Nothing in this folder is publishable. `marketing/approved/` remains the only
|
||||
place a human publishes from.
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
# Weekly funnel snapshot
|
||||
|
||||
Generated weekly from the analytics DB + `trials`/`licenses`/`activations`.
|
||||
Format: the funnel numbers, what moved, and the one thing worth acting on.
|
||||
Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days). Previous: 2026-10-15 16:00:00 → 2026-10-22 16:00: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 (page views) | 61 | 56 | +5 |
|
||||
| download click → DMG served (one label) † | 156 | 0 | +156 |
|
||||
| trial started | 2 | 0 | +2 |
|
||||
| checkout page visit † | 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 |
|
||||
|---|---|---|---:|
|
||||
| hn | post | launch | 2 |
|
||||
| direct | — | deploycheck-direct | 1 |
|
||||
| hn | — | flushproof | 1 |
|
||||
| hn | post | deploycheck-ref | 1 |
|
||||
| r_macapps | — | week2 | 1 |
|
||||
| x | — | — | 1 |
|
||||
|
||||
7 of 356 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
|
||||
|
||||
## The one thing that moved
|
||||
|
||||
**download click → DMG served (one label): 0 → 156 (+156).**
|
||||
|
||||
## Before acting on these numbers
|
||||
|
||||
- **356 of 356 rows in the window came from a loopback address** (127.0.0.1 / ::1); every one of the 12035 addressed rows the table has ever held is loopback (13 carry no address). Either this host is the only audience, or the app sits behind a local proxy and `getRequestIP` records the proxy, not the visitor. Either way the address identifies nobody — "unique visitors" collapses to one host and per-channel reach cannot be read from it.
|
||||
- **The download stage is one label, not two.** The landing-door route and the file route that serves the DMG both write `/api/downloads/thenook`, so "download click → DMG served" cannot be split: one served click writes two rows, a click that fails to resolve writes one, and a Sparkle update writes one. Of this window's 156 download rows, 156 are loopback and 117 are not browser user agents (curl, Sparkle, link-preview crawlers).
|
||||
- **Checkout start is not recorded.** `POST /api/the-nook/checkout` — the call that actually creates the Stripe session — writes nothing to analytics; the only trace is the /checkout page view reported above, which is a page, not a started checkout.
|
||||
- **There is no host dimension.** `VisitorAnalytics` records a path, not the site, so the Nook's landing page (`/` on nook.freno.me) is the same row as freno.me's root. "visit" above is site-wide page views, not Nook visits.
|
||||
- **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.
|
||||
|
||||
† Proxy or merged label — see *Before acting on these numbers* above.
|
||||
|
||||
## Sources
|
||||
|
||||
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
|
||||
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
|
||||
- Generated 2026-10-29T16:00:00Z.
|
||||
|
||||
@@ -113,13 +113,11 @@ one thing forbidden: say "TestFlight beta" when the phone exists in a sentence a
|
||||
all, and never claim approvals-from-phone as a shipped capability without its own
|
||||
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||
|
||||
[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring remote-agent marketing.
|
||||
One-click setup works for **Tailscale and authorized known hosts**. Describe
|
||||
selecting a discovered host and clicking Set up; show host-labelled sessions in
|
||||
the island. `RemoteHostsController.setUp` runs installation and a return-path
|
||||
probe. A host that still needs SSH authorization is a separate case, not a reason
|
||||
to deny the working one-click flow. `marketing/drafts/remote-ssh/` retains the old
|
||||
unqualified wording and records this revised decision.
|
||||
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
|
||||
|
||||
@@ -140,7 +138,7 @@ Each row names the evidence, not a feeling. Re-read the verdict before using a r
|
||||
| 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 ("thirteen agents", "8 agents", "all your agents") | **Excluded — verdict B, count removed** | [FRE-359] verdict B (2026-10-29): the page's 13 rows are the registry's first 13 of 18, so no number is publishable and `every` / `all` / `the agents The Nook supports` are banned. Removed from `agents.tsx`, the landing island section and the marquee. [FRE-322] §1 matrix for the sets. |
|
||||
| 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. |
|
||||
| One-click remote setup for Tailscale and authorized known hosts | **Approved by Mike — NOOK-1038** | [gpt-6.1-sol] Restores the scoped flow: host discovery, `RemoteHostsController.setUp`, `RemoteMirror.apply`, and host-labelled sessions. Supersedes the blanket FRE-323 hold, not the requirement for SSH authorization. |
|
||||
| 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. |
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test:coverage": "bun test --coverage",
|
||||
"perf": "bun run scripts/perf-test.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": {
|
||||
|
||||
153
scripts/weekly-metrics.ts
Normal file
153
scripts/weekly-metrics.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { createClient } from "@libsql/client/web";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import {
|
||||
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"))
|
||||
};
|
||||
|
||||
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 ${f.download}, 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);
|
||||
});
|
||||
@@ -761,14 +761,6 @@ a.hover-underline-animation:hover::after {
|
||||
animation: nook-pulse 0.8s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
/* Directional dashes in the power-flow preview. */
|
||||
.nook-power-flow {
|
||||
animation: nook-power-flow 1.6s linear infinite;
|
||||
}
|
||||
@keyframes nook-power-flow {
|
||||
to { stroke-dashoffset: -21; }
|
||||
}
|
||||
|
||||
/* Live signal inside a mixer fader */
|
||||
.nook-meter {
|
||||
transform-origin: left center;
|
||||
@@ -848,7 +840,6 @@ a.hover-underline-animation:hover::after {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nook-power-flow,
|
||||
.nook-meter,
|
||||
.nook-wave,
|
||||
.nook-progress,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IslandPill,
|
||||
AgentDotGrid,
|
||||
CALM,
|
||||
WARM,
|
||||
ACCENT,
|
||||
CRITICAL,
|
||||
ModuleCard,
|
||||
@@ -23,10 +24,9 @@ import {
|
||||
CODEX
|
||||
} from "./ModuleMocks";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { BatteryDemo, CalendarDemo, MixerDemo } from "./UtilityDemos";
|
||||
import {
|
||||
TerminalReplyDemo,
|
||||
RemoteHostDemo,
|
||||
MixerDemo,
|
||||
CaptureDemo,
|
||||
FloatingActionsDemo
|
||||
} from "./MoreDemos";
|
||||
@@ -168,11 +168,11 @@ function FleetDemo() {
|
||||
{phase() === "flare"
|
||||
? "Codex needs your input"
|
||||
: phase() === "resolve"
|
||||
? "Reply sent. Agents running."
|
||||
: "Agents running"}
|
||||
? "Answer delivered — agents back to work"
|
||||
: "Fleet running calmly"}
|
||||
</p>
|
||||
<p class="mt-0.5 text-[11px] text-white/50">
|
||||
{phase() === "flare" ? "Question waiting in the island" : "\u00a0"}
|
||||
{phase() === "flare" ? "question · in the island now" : "\u00a0"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +234,7 @@ function ApprovalDemo() {
|
||||
prompt="harden the bridge"
|
||||
state={
|
||||
asking()
|
||||
? "Approve Bash: swift test --filter Bridge?"
|
||||
? "Approve Bash — swift test --filter Bridge?"
|
||||
: "Running swift test --filter Bridge…"
|
||||
}
|
||||
status={asking() ? "attention" : "running"}
|
||||
@@ -408,6 +408,313 @@ function FansDemo() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 4: the charge ceiling holds, top-up overrules ───── */
|
||||
|
||||
function BatteryDemo() {
|
||||
const [level, setLevel] = createSignal(62);
|
||||
const [phase, setPhase] = createSignal<"charge" | "held" | "topup">("charge");
|
||||
onMount(() => {
|
||||
let frame = 0;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const ramp = (target: number, durationMs: number) => {
|
||||
cancelAnimationFrame(frame);
|
||||
const start = level();
|
||||
const startTime = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min((now - startTime) / durationMs, 1);
|
||||
setLevel(start + (target - start) * t);
|
||||
if (t < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
const loop = () => {
|
||||
setLevel(62);
|
||||
setPhase("charge");
|
||||
ramp(80, 2600);
|
||||
timers.push(setTimeout(() => setPhase("held"), 2800));
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
setPhase("topup");
|
||||
ramp(100, 2600);
|
||||
}, 5200)
|
||||
);
|
||||
};
|
||||
loop();
|
||||
const interval = setInterval(loop, 9600);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
timers.forEach(clearTimeout);
|
||||
cancelAnimationFrame(frame);
|
||||
});
|
||||
});
|
||||
|
||||
const pct = () => Math.round(level());
|
||||
const watts = () => (phase() === "held" ? null : pct() >= 100 ? 9 : 38);
|
||||
const subline = () =>
|
||||
phase() === "held"
|
||||
? "charging paused — at the limit"
|
||||
: pct() >= 100
|
||||
? "topped up — re-arms on unplug"
|
||||
: phase() === "topup"
|
||||
? `${watts()} W · top-up past the limit`
|
||||
: `${watts()} W · ${Math.max(1, Math.round((100 - pct()) * 1.5))} min to full`;
|
||||
return (
|
||||
<ModuleCard class="mx-auto w-full max-w-[320px]">
|
||||
<div class="space-y-3 text-left">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-semibold tracking-wide text-white/40 uppercase">
|
||||
Battery
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-md px-2 py-0.5 font-mono text-[10px] font-semibold transition-transform duration-300"
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.75)",
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
transform: phase() === "held" ? "scale(1.12)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
Limit 80%
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="font-mono text-[22px] font-semibold text-white">
|
||||
{pct()}%
|
||||
</span>
|
||||
<span class="text-[11px] text-white/50">{subline()}</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative h-[6px] overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={{
|
||||
width: `${pct()}%`,
|
||||
background: pct() > 80 ? WARM : CALM,
|
||||
transition: "background 0.4s"
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
class="absolute top-0 h-full w-px"
|
||||
style={{ left: "80%", background: "rgba(255,255,255,0.5)" }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors duration-500"
|
||||
style={{
|
||||
background:
|
||||
phase() === "topup" ? "rgba(110,167,255,0.10)" : "transparent"
|
||||
}}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="text-[11px] font-semibold text-white/85">Top-up mode</p>
|
||||
<p class="text-[9px] text-white/40">
|
||||
charge past the limit while plugged in
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="relative inline-flex h-[16px] w-[30px] items-center rounded-full transition-colors duration-300"
|
||||
style={{
|
||||
background:
|
||||
phase() === "topup" ? ACCENT : "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="absolute h-[12px] w-[12px] rounded-full bg-white transition-all duration-300"
|
||||
style={{ left: phase() === "topup" ? "16px" : "2px" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
width="10"
|
||||
height="13"
|
||||
viewBox="0 0 10 13"
|
||||
style={{
|
||||
animation: watts()
|
||||
? "nook-pulse 1.1s ease-in-out infinite"
|
||||
: "none",
|
||||
opacity: watts() ? 1 : 0.3
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M6.2 0 0.8 7h2.6l-1 5.2L7.8 5.4H5l1.2-5.4z"
|
||||
fill="#FFD23F"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[10px] text-white/50">
|
||||
{phase() === "held"
|
||||
? "holding — the pack stays young longer"
|
||||
: phase() === "topup"
|
||||
? "topping up — re-arms when you unplug"
|
||||
: "on the way to the ceiling"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 5: the slot counts down, then opens the day ──────── */
|
||||
|
||||
const WEEK_LETTERS = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
const DAY_EVENTS = [
|
||||
{ time: "09:30–09:45", title: "Standup", loc: "Daily", meeting: true },
|
||||
{ time: "15:30–16:00", title: "Design review", loc: "4th floor", meeting: false }
|
||||
];
|
||||
|
||||
function CalendarDemo() {
|
||||
const [mins, setMins] = createSignal(14);
|
||||
const [today, setToday] = createSignal(new Date());
|
||||
onMount(() => {
|
||||
setToday(new Date());
|
||||
const interval = setInterval(
|
||||
() => setMins((m) => (m <= 0 ? 14 : m - 1)),
|
||||
900
|
||||
);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
const imminent = () => mins() <= 5;
|
||||
const dialDays = () => {
|
||||
const base = today();
|
||||
return [-4, -3, -2, -1, 0, 1, 2, 3, 4].map((off) => {
|
||||
const d = new Date(
|
||||
base.getFullYear(),
|
||||
base.getMonth(),
|
||||
base.getDate() + off
|
||||
);
|
||||
return {
|
||||
letter: WEEK_LETTERS[d.getDay()],
|
||||
num: d.getDate(),
|
||||
today: off === 0,
|
||||
past: off < 0,
|
||||
dots: (d.getDate() * 7 + d.getDay() * 3) % 4
|
||||
};
|
||||
});
|
||||
};
|
||||
return (
|
||||
/* Fixed height: the day panel expands into reserved space, never
|
||||
shifts the page (matters most on mobile's stacked layout). */
|
||||
<div class="flex h-[316px] flex-col items-center gap-4">
|
||||
<IslandPill
|
||||
campfire="running"
|
||||
right={
|
||||
<div class="flex items-center gap-1">
|
||||
<svg width="10" height="8" viewBox="0 0 12 9" class="shrink-0">
|
||||
<path
|
||||
d="M0 2A1.4 1.4 0 0 1 1.4 0.6h5.6A1.4 1.4 0 0 1 8.4 2v5A1.4 1.4 0 0 1 7 8.4H1.4A1.4 1.4 0 0 1 0 7z M12 2.6 9 4.5l3 1.9z"
|
||||
fill="rgba(255,255,255,0.75)"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="whitespace-nowrap font-mono text-[9.5px] font-semibold text-white/90"
|
||||
style={{ color: imminent() ? "#ff9e42" : "rgba(255,255,255,0.9)" }}
|
||||
>
|
||||
{mins() === 0 ? "now" : `${mins()}m`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* the island opening onto the day panel */}
|
||||
<div
|
||||
class="w-full max-w-[380px] overflow-hidden rounded-[14px] bg-black transition-all duration-700"
|
||||
style={{
|
||||
"max-height": imminent() ? "240px" : "0px",
|
||||
opacity: imminent() ? 1 : 0,
|
||||
transform: imminent() ? "translateY(0)" : "translateY(-8px)"
|
||||
}}
|
||||
>
|
||||
<div class="px-4 pt-3 pb-3 text-left">
|
||||
<div class="mb-2 flex items-center">
|
||||
<span class="text-[11px] font-semibold text-white/50">
|
||||
Calendar
|
||||
</span>
|
||||
<span class="ml-auto flex h-4 w-4 items-center justify-center text-[12px] font-semibold text-white/50">
|
||||
+
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-2 flex gap-1">
|
||||
<For each={dialDays()}>
|
||||
{(d) => (
|
||||
<div
|
||||
class="flex flex-1 flex-col items-center gap-[3px]"
|
||||
style={{ opacity: d.today ? 1 : d.past ? 0.55 : 0.8 }}
|
||||
>
|
||||
<span class="text-[8px] font-semibold text-white/45">
|
||||
{d.letter}
|
||||
</span>
|
||||
<span
|
||||
class="flex h-[20px] w-[20px] items-center justify-center rounded-full text-[10px]"
|
||||
style={{
|
||||
background: d.today
|
||||
? "rgb(255,158,66)"
|
||||
: "rgba(255,255,255,0.06)",
|
||||
color: "rgba(255,255,255,0.95)",
|
||||
"font-weight": d.today ? 700 : 500
|
||||
}}
|
||||
>
|
||||
{d.num}
|
||||
</span>
|
||||
<span class="flex h-[4px] gap-[2px]">
|
||||
<For each={Array.from({ length: Math.min(d.dots, 3) })}>
|
||||
{() => (
|
||||
<span
|
||||
class="rounded-full"
|
||||
style={{
|
||||
width: "3.5px",
|
||||
height: "3.5px",
|
||||
background: d.today
|
||||
? "rgb(255,158,66)"
|
||||
: "rgba(255,255,255,0.55)"
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class="mb-1 h-px"
|
||||
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||
/>
|
||||
<For each={DAY_EVENTS}>
|
||||
{(ev) => (
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors duration-500"
|
||||
style={{
|
||||
background:
|
||||
ev.meeting && imminent()
|
||||
? "rgba(72,151,178,0.16)"
|
||||
: "transparent"
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p class="font-mono text-[10px] text-white/45">{ev.time}</p>
|
||||
<p class="text-[11px] font-semibold text-white">{ev.title}</p>
|
||||
{ev.loc && <p class="text-[10px] text-white/35">{ev.loc}</p>}
|
||||
</div>
|
||||
{ev.meeting && (
|
||||
<span
|
||||
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white transition-colors duration-500"
|
||||
style={{
|
||||
background: imminent() ? ACCENT : "rgba(255,255,255,0.10)"
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 6: the lens only wakes for a click ──────────────── */
|
||||
|
||||
function CameraDemo() {
|
||||
@@ -490,8 +797,8 @@ function CameraDemo() {
|
||||
</div>
|
||||
<p class="text-[10px] text-white/40">
|
||||
{phase() === "live"
|
||||
? "Camera on while this panel is open"
|
||||
: "Camera off until you click Start"}
|
||||
? "live — stops the moment you leave the panel"
|
||||
: "inert — the lens stays dark until you click"}
|
||||
</p>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
@@ -509,11 +816,11 @@ function CampfireDemo() {
|
||||
"error"
|
||||
];
|
||||
const labels = [
|
||||
"Running",
|
||||
"Question",
|
||||
"Ready",
|
||||
"Idle",
|
||||
"Error"
|
||||
"fleet running",
|
||||
"pending question",
|
||||
"ready to review",
|
||||
"all quiet",
|
||||
"something failed"
|
||||
];
|
||||
return (
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
@@ -541,13 +848,13 @@ export default function FeatureBreakdowns() {
|
||||
<div class="space-y-24">
|
||||
<BreakdownSection
|
||||
kicker="The island"
|
||||
title="See which agents need your attention"
|
||||
body="Each square represents an agent session. Its color shows whether the agent is working, finished, or waiting for you. Questions appear in the island so you can respond without switching windows."
|
||||
title="Your whole fleet, in one pixel strip"
|
||||
body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
|
||||
bullets={[
|
||||
"Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, Cursor and more",
|
||||
"Connect custom agents using the published integration contract",
|
||||
"Choose which information appears in the compact view",
|
||||
"Works on displays without a notch, centered at the top of the screen"
|
||||
"Custom agents: your own coding agent can wire itself in from the published contract",
|
||||
"Slot rankings — you choose which data earns the notch",
|
||||
"You don't need a notch — external displays get the same island, floating top-center"
|
||||
]}
|
||||
>
|
||||
<FleetDemo />
|
||||
@@ -555,12 +862,12 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Approvals"
|
||||
title="Approve agent requests"
|
||||
body="When an agent needs permission, the island opens with a request card above your sessions. Review the tool and its input, then allow or deny it. You can include a reason when you deny a request."
|
||||
title="Answer from the island — or your phone"
|
||||
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={[
|
||||
"Review permissions above the session list and answer questions in the session row",
|
||||
"Allow for session approves subsequent requests in that session",
|
||||
"Respond from iPhone or Apple Watch with the TestFlight beta",
|
||||
"Approval cards pin above the session list; questions answer inside their row",
|
||||
"Allow for session waves through the rest of that session's requests",
|
||||
"Answer from iPhone or Apple Watch too, now in TestFlight beta",
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
@@ -569,8 +876,8 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Terminals"
|
||||
title="Reply to agents and open their terminals"
|
||||
body="Send a reply from the island to the terminal pane running that session. Use Jump to bring its window forward when you want to work in the terminal."
|
||||
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",
|
||||
@@ -580,27 +887,12 @@ export default function FeatureBreakdowns() {
|
||||
<TerminalReplyDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Remote"
|
||||
title="Set up remote agents in one click"
|
||||
body="Choose a Tailscale peer or an authorized host from your SSH config and click Set up. The Nook installs its integrations and checks that the host can reach your Mac."
|
||||
bullets={[
|
||||
"Local and remote sessions in the same island, labelled by host",
|
||||
"Remote integrations follow the agents installed on this Mac",
|
||||
"Direct connections over Tailscale, or an SSH tunnel for other hosts",
|
||||
"Uses the host\'s existing SSH authorization"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<RemoteHostDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="The campfire"
|
||||
title="Agent status in the campfire"
|
||||
body="The pixel-art campfire changes with your agents' activity. Different animations indicate work in progress, a question waiting for you, or an error."
|
||||
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={[
|
||||
"Separate animations for idle, running, ready, question, and error states",
|
||||
"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"
|
||||
]}
|
||||
@@ -610,13 +902,13 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Cooling"
|
||||
title="Monitor and control your Mac's fans"
|
||||
body="Check each fan's speed and choose how cooling responds to load. Keep the stock fan curve, use Aggressive for earlier cooling, or start a MAX session to run the fans at full speed."
|
||||
title="Fans that answer to you, not the scheduler"
|
||||
body="Live RPM for every fan, and the curve is yours. Apple's stock curve waits until you're already cooking — swap to Aggressive and it climbs earlier and harder, or slam MAX before the compile."
|
||||
bullets={[
|
||||
"Switch between Stock, Aggressive, and MAX",
|
||||
"See active MAX and COOL sessions in the compact view",
|
||||
"CPU and GPU temperature readings change color as they rise",
|
||||
"Hardware monitoring runs locally on your Mac"
|
||||
"Stock, Aggressive, and MAX — swap curves in one click",
|
||||
"MAX and COOL sessions surface right in the pill",
|
||||
"CPU/GPU die temps tier-color before things get loud",
|
||||
"All local: your hardware data never leaves the device"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
@@ -625,14 +917,14 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Power"
|
||||
title="Set a battery charge limit"
|
||||
body="Set the charge limit on the battery bar, or enter a percentage. The power diagram shows how much power goes to your Mac and battery. Turn on Top-up mode when you need a full charge."
|
||||
title="Wall power, on your terms"
|
||||
body="Live watts from the adapter to the battery to the chassis — and a charge ceiling you set yourself. On recent macOS The Nook drives the system's own limiter; below that it enforces the limit itself. One slider, 50–100%, any version."
|
||||
bullets={[
|
||||
"Drag the limit marker or type a percentage; available limits depend on your Mac",
|
||||
"Uses macOS charge limits where available, with helper-based control on supported Macs",
|
||||
"Top-up mode allows charging past the limit and turns off when you unplug",
|
||||
"Overheat protection can pause charging when the battery gets hot",
|
||||
"Separate readings for system draw and battery charging, not a wall-power measurement"
|
||||
"One slider, 50–100%, on any macOS version",
|
||||
"Hooks the system limiter where it exists, enforces elsewhere",
|
||||
"Top-up mode charges past, then re-arms on unplug",
|
||||
"Thermal protection pauses charging when the pack runs hot",
|
||||
"Energy flow figure: adapter → battery → system, live watts"
|
||||
]}
|
||||
>
|
||||
<BatteryDemo />
|
||||
@@ -640,12 +932,12 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Sound"
|
||||
title="Adjust volume and output for each app"
|
||||
body="Set an app's volume without changing the others, mute it, or choose a different output. You can play music through headphones and keep a meeting on the speakers."
|
||||
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",
|
||||
"Includes audio from command-line players"
|
||||
"Catches players that aren't apps, like command-line podcast tools"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
@@ -654,8 +946,8 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Capture"
|
||||
title="Take screenshots and record your screen"
|
||||
body="Capture an area, a whole screen, or one app. During a recording, controls beside the island let you pause, stop, and toggle system audio or your microphone."
|
||||
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",
|
||||
@@ -668,13 +960,13 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Your day"
|
||||
title="Check your calendar and join meetings"
|
||||
body="Browse your calendar from the day strip and open a meeting from its event row. The compact view shows today's date, a countdown before an event, and an ON AIR indicator while a meeting is in progress."
|
||||
title="Meetings come to the notch"
|
||||
body="Today sits in a dial — days behind, days ahead, dots where things happen. The slot counts your next call down, and when it gets close the island opens the day on its own."
|
||||
bullets={[
|
||||
"Date, countdown, and meeting progress in the compact view",
|
||||
"Choose how early the island opens before a meeting",
|
||||
"Join meetings from the video button beside an event",
|
||||
"Uses your Mac's calendars and reminders"
|
||||
"Next meeting counts down right in the slot",
|
||||
"Near a call, the island opens the day itself",
|
||||
"Meeting links get an Open button in the row",
|
||||
"Real calendars and reminders — no sync service"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
@@ -683,13 +975,13 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Camera"
|
||||
title="Preview your camera before a call"
|
||||
body="Check your camera in the island before joining a call. The preview starts only when you click Start camera and stops when you leave the panel."
|
||||
title="A lens that stays asleep"
|
||||
body="A live camera preview in the island for the seconds before a call. It never wakes on its own: permission-gated, inert until you click Start, and dead the moment you leave the panel."
|
||||
bullets={[
|
||||
"Requires camera permission and a click on Start camera",
|
||||
"Preview runs only while the panel is open",
|
||||
"Nothing watches until Start camera gets a click",
|
||||
"Live only while the panel sits open",
|
||||
"A device picker for every connected camera",
|
||||
"Add the camera module to a panel when you want it"
|
||||
"Opt-in module — in no default panel"
|
||||
]}
|
||||
>
|
||||
<CameraDemo />
|
||||
@@ -697,8 +989,8 @@ export default function FeatureBreakdowns() {
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Floating actions"
|
||||
title="Add shortcuts beside the island"
|
||||
body="Add up to seven actions beside the expanded island. Use them to open apps, run shortcuts, or change settings without opening a panel."
|
||||
title="Your buttons, orbiting the island"
|
||||
body="Pin up to seven one-tap actions around the expanded island. They ride its expansion in and out, so the things you reach for most are always one click away."
|
||||
bullets={[
|
||||
"Run an Apple Shortcut, open an app or URL, or run a shell command",
|
||||
"Toggle Low Power Mode, top-up, or overheat protection",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
||||
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
|
||||
import { ApprovalCardMock, SessionRowMock, DieLine, CLAUDE, CODEX } from "./ModuleMocks";
|
||||
import { ApprovalCardMock, DieLine, CLAUDE } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Feature collage: five cells sold as the app's actual UI. The hero cell
|
||||
@@ -99,31 +99,6 @@ function FansPanelMock() {
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteMock() {
|
||||
return (
|
||||
<div class="space-y-1.5">
|
||||
<SessionRowMock
|
||||
title="api · main"
|
||||
prompt="fix the flaky test"
|
||||
state="Running pytest -x…"
|
||||
status="running"
|
||||
agent={CLAUDE}
|
||||
host="build-box"
|
||||
age="4m"
|
||||
/>
|
||||
<SessionRowMock
|
||||
title="trainer · fp16"
|
||||
prompt="benchmark fp16"
|
||||
state="Done · replied 1m ago"
|
||||
status="ready"
|
||||
agent={CODEX}
|
||||
host="gpu-rig"
|
||||
age="12m"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Deterministic usage wander for the traces. */
|
||||
const wander = (n: number, k: number) =>
|
||||
0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k);
|
||||
@@ -162,7 +137,7 @@ export default function FeatureCollage() {
|
||||
Why the Nook
|
||||
</p>
|
||||
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||
Keep track of your agents while you work
|
||||
One glance. Everything your agents are doing.
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||
@@ -173,12 +148,12 @@ export default function FeatureCollage() {
|
||||
The island
|
||||
</p>
|
||||
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight">
|
||||
Agent status at the top of your screen
|
||||
Every agent, one glance
|
||||
</h3>
|
||||
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
||||
The island sits around your Mac's notch, with agent activity on
|
||||
the left and your choice of information on the right. Blue means
|
||||
running, green means ready, and amber means an agent needs you.
|
||||
A pill tucked into your notch — a campfire for your whole fleet
|
||||
on the left, live data on the right. Blue running, green ready,
|
||||
amber needs you.
|
||||
</p>
|
||||
</div>
|
||||
<div class="my-10 flex w-full justify-center">
|
||||
@@ -211,15 +186,15 @@ export default function FeatureCollage() {
|
||||
/>
|
||||
</div>
|
||||
<p class="text-subtext0 -mb-1 text-center text-xs">
|
||||
Open the island to see your sessions
|
||||
expand for the full panel ↓
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Approvals */}
|
||||
<CollageCell
|
||||
kicker="Approvals"
|
||||
title="Review permission requests"
|
||||
body="Review permission requests above your sessions and choose Allow, Allow for session, or Deny. The iPhone and Apple Watch beta lets you respond away from your Mac."
|
||||
title="Unblock agents, anywhere"
|
||||
body="Permission prompts pin as cards above your sessions — Allow, Allow for session, or Deny. Away from the desk, answer from your iPhone or Apple Watch (beta)."
|
||||
>
|
||||
<InkStage>
|
||||
<div class="w-full max-w-sm px-3">
|
||||
@@ -236,8 +211,8 @@ export default function FeatureCollage() {
|
||||
{/* Fans */}
|
||||
<CollageCell
|
||||
kicker="Cooling"
|
||||
title="Fan speeds and cooling modes"
|
||||
body="Monitor each fan's speed and choose a cooling mode. Keep automatic control or start a MAX session before a demanding task."
|
||||
title="Silence it or max it"
|
||||
body="Read both fans live, then take over the curve completely. Flip to MAX before a compile, whisper-quiet when the fleet idles."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full max-w-[260px] text-left">
|
||||
@@ -249,8 +224,8 @@ export default function FeatureCollage() {
|
||||
{/* Gauges */}
|
||||
<CollageCell
|
||||
kicker="Your Mac"
|
||||
title="CPU and GPU monitoring"
|
||||
body="See CPU and GPU temperatures beside their last 60 seconds of usage. Temperature readings turn amber at 70°C and red at 85°C."
|
||||
title="The gauges that matter"
|
||||
body="Each die's temperature beside its 60-second usage trace, tinted amber from 70° and red at 85°, so you see a compile heating up before the fans do."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full max-w-[300px] text-left">
|
||||
@@ -258,27 +233,15 @@ export default function FeatureCollage() {
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Remote"
|
||||
title="Agents on other machines"
|
||||
body="One-click setup for Tailscale and authorized known hosts. Remote sessions appear in the island with the host name beside the agent."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full min-w-0 px-3" role="img" aria-label="Remote agent sessions on build-box and gpu-rig">
|
||||
<RemoteMock />
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
</div>
|
||||
|
||||
<p class="text-subtext1 mt-6 text-center text-xs">
|
||||
Also includes a volume mixer, screen capture, timers, weather, and more.{" "}
|
||||
Plus a mixer, capture, timers, weather, and a dozen more modules —{" "}
|
||||
<a
|
||||
href="#island-extras"
|
||||
class="underline decoration-dotted hover:opacity-80"
|
||||
>
|
||||
Browse the other modules
|
||||
see them all ↓
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ACCENT,
|
||||
CALM,
|
||||
WARM,
|
||||
CRITICAL,
|
||||
CARD_FILL,
|
||||
CARD_STROKE
|
||||
} from "./IslandMock";
|
||||
@@ -570,8 +571,8 @@ function KeepAwakeMock() {
|
||||
|
||||
const MODES = [
|
||||
{ name: "Auto", note: "follows each agent's own settings", card: "asks", tint: "rgba(234,179,8,0.10)" },
|
||||
{ name: "Yolo", note: "automatically allows received requests", card: "passed", tint: "rgba(111,185,130,0.12)" },
|
||||
{ name: "Verify", note: "received requests wait for approval", card: "held", tint: "rgba(229,72,77,0.12)" }
|
||||
{ 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() {
|
||||
@@ -772,14 +773,14 @@ export default function IslandExtras() {
|
||||
<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">
|
||||
More modules
|
||||
And everything else
|
||||
</p>
|
||||
<h2 class="text-text mb-3 text-center text-3xl font-bold">
|
||||
Other tools in the island
|
||||
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">
|
||||
Add the modules you need and arrange them in your panels.
|
||||
They're all included with your license.
|
||||
Every module below lives in the same notch, arranged however you
|
||||
like, under the same one-time license.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -792,8 +793,8 @@ export default function IslandExtras() {
|
||||
<CollageCell
|
||||
class="md:col-span-2"
|
||||
kicker="Now Playing"
|
||||
title="Playback controls and track information"
|
||||
body="See what's playing and control playback. For sources without artwork, including some browsers, the compact view shows an audio waveform."
|
||||
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 />
|
||||
@@ -802,8 +803,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Timers"
|
||||
title="Run several timers at once"
|
||||
body="Keep multiple timers running and check their progress in the island. When a timer finishes, it rings and keeps the island open until you dismiss it."
|
||||
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 />
|
||||
@@ -812,8 +813,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Weather"
|
||||
title="Weather for your saved places"
|
||||
body="Check hourly and daily forecasts for saved places. The compact view shows the current conditions and temperature."
|
||||
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 />
|
||||
@@ -823,8 +824,8 @@ export default function IslandExtras() {
|
||||
<CollageCell
|
||||
class="md:col-span-2"
|
||||
kicker="Interceptions"
|
||||
title="Volume and brightness controls"
|
||||
body="Show volume and brightness changes below the island instead of the system overlay. Adjust supported external displays and use extra brightness on compatible XDR displays."
|
||||
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">
|
||||
@@ -835,8 +836,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Approval modes"
|
||||
title="Choose how approvals are handled"
|
||||
body="Auto follows each agent's settings. Yolo automatically allows requests that reach The Nook. Verify waits for your decision on each request it receives."
|
||||
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 />
|
||||
@@ -845,8 +846,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Network"
|
||||
title="Network information and speed tests"
|
||||
body="View and copy your public IP, ISP, gateway, and DNS details. Check for VPN routes or run a speed test."
|
||||
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 />
|
||||
@@ -855,8 +856,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Disks"
|
||||
title="Disk usage and drive health"
|
||||
body="Check read and write speeds, free space, and SMART health information for supported drives."
|
||||
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 />
|
||||
@@ -865,8 +866,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Keep Awake"
|
||||
title="Keep your Mac awake"
|
||||
body="Keep your Mac awake for a set time, while plugged in, with the lid closed, or while your agents are working."
|
||||
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 />
|
||||
@@ -875,7 +876,7 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Lock Screen"
|
||||
title="Agent status on the 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">
|
||||
@@ -885,8 +886,8 @@ export default function IslandExtras() {
|
||||
|
||||
<CollageCell
|
||||
kicker="Layout"
|
||||
title="Arrange your panels"
|
||||
body="Drag and resize modules, choose how much detail they show, and change compact-view priorities with your Focus mode."
|
||||
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 />
|
||||
|
||||
@@ -77,12 +77,6 @@ const GLYPHS: Record<string, () => JSX.Element> = {
|
||||
<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" />,
|
||||
video: () => <path d="M2 4h8v8H2Z m9 3 3-2v6l-3-2Z" fill="currentColor" />,
|
||||
pin: () => <path d="m6 2 6 6-3 1-2 3-1-2-4 4m2-8 2-2Z" stroke="currentColor" stroke-width="1.2" fill="none" stroke-linejoin="round" />,
|
||||
eye: () => <><path d="M1 8s2.5-4 7-4 7 4 7 4-2.5 4-7 4-7-4-7-4Z" stroke="currentColor" stroke-width="1.2" fill="none" /><circle cx="8" cy="8" r="2" fill="currentColor" /></>,
|
||||
plug: () => <path d="M5 1v4m6-4v4M3 5h10v3a5 5 0 0 1-4 4.9V16H7v-3.1A5 5 0 0 1 3 8Z" stroke="currentColor" stroke-width="1.3" fill="none" />,
|
||||
battery: () => <><rect x="1" y="4" width="12" height="8" rx="1.5" stroke="currentColor" stroke-width="1.2" fill="none" /><path d="M14 6v4M3 6h5v4H3Z" stroke="currentColor" stroke-width="1.2" /></>,
|
||||
laptop: () => <path d="M3 2.5h10v9H3Z M1 13.5h14" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linejoin="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" />
|
||||
)
|
||||
|
||||
@@ -7,20 +7,21 @@ import {
|
||||
STATUS,
|
||||
ACCENT,
|
||||
CALM,
|
||||
CRITICAL,
|
||||
CARD_STROKE
|
||||
} from "./IslandMock";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { Glyph, WingCapsule, SessionRowMock, CODEX, type GlyphName } from "./ModuleMocks";
|
||||
import { Glyph, WingCapsule, type GlyphName } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Live demos for the breakdowns added with the 0.6–0.10 feature set:
|
||||
* terminal replies, remote setup, Capture (module + Wings), and
|
||||
* 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, update and run the tests.";
|
||||
const REPLY = "yes — and run them";
|
||||
|
||||
export function TerminalReplyDemo() {
|
||||
const [typed, setTyped] = createSignal(0);
|
||||
@@ -152,106 +153,124 @@ export function TerminalReplyDemo() {
|
||||
);
|
||||
}
|
||||
|
||||
/** RemoteMirror's progress steps, in order. */
|
||||
const SETUP_STEPS = [
|
||||
"connecting",
|
||||
"pushing the hook binary",
|
||||
"configuring agents",
|
||||
"checking the host can reach this Mac"
|
||||
/* ── 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 RemoteHostDemo() {
|
||||
/* -1 not set up · 0…3 setting up · 4 live */
|
||||
const [step, setStep] = createSignal(-1);
|
||||
const [pressed, setPressed] = createSignal(false);
|
||||
useDemoLoop(11000, [
|
||||
[0, () => (setStep(-1), setPressed(false))],
|
||||
[1400, () => setPressed(true)],
|
||||
[1700, () => (setPressed(false), setStep(0))],
|
||||
[2700, () => setStep(1)],
|
||||
[3700, () => setStep(2)],
|
||||
[4700, () => setStep(3)],
|
||||
[5800, () => setStep(4)]
|
||||
export function MixerDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
useDemoLoop(9600, [
|
||||
[0, () => setStep(0)],
|
||||
[1800, () => setStep(1)],
|
||||
[3800, () => setStep(2)],
|
||||
[5800, () => setStep(3)]
|
||||
]);
|
||||
const live = () => step() === 4;
|
||||
const working = () => step() >= 0 && step() < 4;
|
||||
const chip = () =>
|
||||
live()
|
||||
? { color: STATUS.ready, label: "Live" }
|
||||
: working()
|
||||
? { color: "rgba(255,255,255,0.55)", label: "Setting up" }
|
||||
: { color: "rgba(255,255,255,0.35)", label: "Not set up" };
|
||||
|
||||
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 (
|
||||
<div class="relative mx-auto w-full max-w-[360px] space-y-3" role="img" aria-label="One-click remote setup preview for an authorized Tailscale host">
|
||||
{/* the host's card in Settings ▸ Agents ▸ Remote hosts */}
|
||||
<ModuleCard>
|
||||
<div class="space-y-2 text-left">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
|
||||
<p class="truncate text-[11px] text-white/45">tailnet peer</p>
|
||||
</div>
|
||||
<Show when={working()}>
|
||||
<span class="h-3 w-3 animate-spin rounded-full border-[1.5px] border-white/25 border-t-white/80" />
|
||||
</Show>
|
||||
<span class="flex items-center gap-1.5 text-[10.5px] font-medium text-white/85">
|
||||
<span
|
||||
class="h-[7px] w-[7px] rounded-full transition-colors duration-300"
|
||||
style={{ background: chip().color }}
|
||||
/>
|
||||
{chip().label}
|
||||
<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>
|
||||
<Show
|
||||
when={live()}
|
||||
fallback={
|
||||
<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="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
|
||||
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: ACCENT,
|
||||
opacity: working() ? 0 : 1,
|
||||
transform: pressed() ? "scale(0.92)" : "scale(1)"
|
||||
background:
|
||||
i() === 0 && step() === 2
|
||||
? `${ACCENT}55`
|
||||
: "rgba(255,255,255,0.07)"
|
||||
}}
|
||||
>
|
||||
Set up
|
||||
{route(i())}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Glyph name="refresh" size={11} class="text-white/50" />
|
||||
<span class="rounded-md bg-white/10 px-2 py-1 text-[11px] font-semibold text-[#ff6961]">
|
||||
Remove
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="min-h-8 text-[11px] text-white/50">
|
||||
{working() ? SETUP_STEPS[step()] : " "}
|
||||
<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>
|
||||
|
||||
{/* The host name stays visible in the session badge. */}
|
||||
<div
|
||||
class="transition-all duration-500"
|
||||
style={{
|
||||
opacity: live() ? 1 : 0,
|
||||
transform: live() ? "translateY(0)" : "translateY(6px)"
|
||||
}}
|
||||
>
|
||||
<SessionRowMock
|
||||
title="trainer · fp16"
|
||||
prompt="benchmark fp16"
|
||||
state="Running python bench.py…"
|
||||
status="running"
|
||||
agent={CODEX}
|
||||
host="gpu-rig"
|
||||
age="now"
|
||||
/>
|
||||
</div>
|
||||
<FakeCursor
|
||||
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
|
||||
at={{ x: 88, y: 16 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ function CompanionDemo() {
|
||||
{allowed() ? "Allowed ✓" : "Claude Code · the-nook"}
|
||||
</p>
|
||||
<p class="text-[9px] leading-snug text-white/60">
|
||||
{allowed() ? "Agent running." : "Run Bash: swift test"}
|
||||
{allowed() ? "Back to work." : "Run Bash: swift test"}
|
||||
</p>
|
||||
<Show when={!allowed()}>
|
||||
<div class="mt-1.5 flex gap-1">
|
||||
@@ -139,7 +139,7 @@ function CompanionDemo() {
|
||||
>
|
||||
<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">
|
||||
Deploy to staging?
|
||||
Ship it to staging?
|
||||
</p>
|
||||
<div class="mt-2 space-y-1">
|
||||
<For each={["Yes", "Not yet"]}>
|
||||
@@ -194,19 +194,19 @@ export default function PhoneBeta(props: { brandColor: string }) {
|
||||
TestFlight beta
|
||||
</span>
|
||||
<h2 class="text-text mb-3 text-3xl font-bold tracking-tight">
|
||||
Use The Nook on iPhone and Apple Watch
|
||||
Your fleet, in your pocket
|
||||
</h2>
|
||||
<p class="text-subtext0 mb-5 leading-relaxed">
|
||||
The companion app is in TestFlight beta. Check your agents,
|
||||
respond to permission requests, and answer questions when you're
|
||||
away from your Mac.
|
||||
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",
|
||||
"Agent status widgets for your Home Screen, Lock Screen, and watch face",
|
||||
"Home Screen, Lock Screen, and watch-face widgets for fleet status",
|
||||
"Mute notifications for a while, or while you're active at the Mac"
|
||||
]}
|
||||
>
|
||||
@@ -255,7 +255,7 @@ export default function PhoneBeta(props: { brandColor: string }) {
|
||||
</form>
|
||||
<Show when={state() === "sent"}>
|
||||
<p class="text-subtext1 mt-4 text-sm">
|
||||
Thanks. We'll email you a TestFlight invite.
|
||||
Thanks. Watch your inbox for a TestFlight invite.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={state() === "limited"}>
|
||||
|
||||
@@ -1,627 +0,0 @@
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import { IslandPill, ModuleCard, CALM, WARM } from "./IslandMock";
|
||||
import { Glyph, type GlyphName } from "./ModuleMocks";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
|
||||
const PAPER = "#f1ead9";
|
||||
const BATTERY = "#8dc9b3";
|
||||
const CALENDAR = "#ff9e42";
|
||||
|
||||
/** SettingsSlider: paper fill, dark knob, and a unity notch on app sliders. */
|
||||
function MixerSlider(props: { level: number; tint?: string; muted?: boolean }) {
|
||||
const fraction = () => props.level / (props.tint ? 125 : 100);
|
||||
const position = () => `calc(9px + (100% - 18px) * ${fraction()})`;
|
||||
return (
|
||||
<div class="relative h-5 min-w-0 flex-1 rounded-full bg-white/10">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 overflow-hidden rounded-full transition-[width] duration-700"
|
||||
style={{ width: `calc(${position()} + 9px)`, background: PAPER }}
|
||||
>
|
||||
<Show when={props.tint && !props.muted}>
|
||||
<div
|
||||
class="nook-meter h-full w-full rounded-full"
|
||||
style={{ background: props.tint }}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.tint}>
|
||||
<span
|
||||
class="absolute top-[7.5px] h-[5px] w-[5px] rounded-full border border-white/55 bg-[#0d0d0f]"
|
||||
style={{ left: "calc(9px + (100% - 18px) * 0.8 - 2.5px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<span
|
||||
class="absolute top-px h-[18px] w-[18px] rounded-full border-[1.5px] shadow-sm transition-[left] duration-700"
|
||||
style={{
|
||||
left: `calc(${position()} - 9px)`,
|
||||
background: "#0d0d0f",
|
||||
"border-color": PAPER
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutePicker(props: { label: string }) {
|
||||
return (
|
||||
<span class="inline-flex max-w-[155px] items-center gap-1 rounded-full bg-white/10 px-2 py-1 text-[11px] text-white/85">
|
||||
<span class="truncate">{props.label}</span>
|
||||
<Glyph name="chevron" size={8} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const MIXER_APPS = [
|
||||
{ name: "Music", color: "#fa5c7c", glyph: "♫" },
|
||||
{ name: "Browser", color: CALM, glyph: "◎" },
|
||||
{ name: "Meeting", color: BATTERY, glyph: "▰" }
|
||||
];
|
||||
|
||||
/** VolumeMixerPanelView and MixerAppRow, including the current route controls. */
|
||||
export function MixerDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
useDemoLoop(10800, [
|
||||
[0, () => setStep(0)],
|
||||
[2400, () => setStep(1)],
|
||||
[4800, () => setStep(2)],
|
||||
[7200, () => setStep(3)]
|
||||
]);
|
||||
const muted = (i: number) => i === 1 && step() >= 1;
|
||||
const volume = (i: number) =>
|
||||
i === 0 ? (step() >= 3 ? 38 : 100) : i === 1 ? 55 : 86;
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Volume mixer preview with output, system sounds, microphone, and per-app volume, mute and routing controls"
|
||||
>
|
||||
<ModuleCard class="mx-auto w-full max-w-[360px]">
|
||||
<div class="space-y-3 text-left text-white">
|
||||
<div class="flex items-center gap-2" aria-label="Output volume">
|
||||
<Glyph name="speaker" class="text-white/50" />
|
||||
<MixerSlider level={65} />
|
||||
<span class="w-[34px] text-right font-mono text-[11px] text-white/60">
|
||||
65%
|
||||
</span>
|
||||
</div>
|
||||
<For
|
||||
each={[
|
||||
{
|
||||
label: "Output",
|
||||
icon: "speaker",
|
||||
device: "MacBook Pro Speakers"
|
||||
},
|
||||
{
|
||||
label: "System sounds",
|
||||
icon: "bell",
|
||||
device: "MacBook Pro Speakers"
|
||||
},
|
||||
{
|
||||
label: "Microphone",
|
||||
icon: "mic",
|
||||
device: "MacBook Pro Microphone"
|
||||
}
|
||||
]}
|
||||
>
|
||||
{(route) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<Glyph name={route.icon} class="text-white/50" />
|
||||
<span class="flex-1 text-[12px] font-medium">
|
||||
{route.label}
|
||||
</span>
|
||||
<RoutePicker label={route.device} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="h-px bg-white/[0.06]" />
|
||||
<For each={MIXER_APPS}>
|
||||
{(app, i) => (
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[4px] text-[10px] text-white"
|
||||
style={{ background: app.color }}
|
||||
>
|
||||
{app.glyph}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] font-medium">
|
||||
{app.name}
|
||||
</span>
|
||||
<RoutePicker
|
||||
label={i() === 0 && step() >= 2 ? "Headphones" : "Default"}
|
||||
/>
|
||||
<span aria-label="Pin app" class="text-white/50">
|
||||
<Glyph name="pin" size={10} />
|
||||
</span>
|
||||
<span aria-label="Hide app" class="text-white/50">
|
||||
<Glyph name="eye" size={10} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pl-[26px]">
|
||||
<MixerSlider
|
||||
level={volume(i())}
|
||||
tint={app.color}
|
||||
muted={muted(i())}
|
||||
/>
|
||||
<span class="w-[34px] text-right font-mono text-[11px] text-white/60">
|
||||
{muted(i()) ? 0 : volume(i())}%
|
||||
</span>
|
||||
<Glyph
|
||||
name={muted(i()) ? "speakerOff" : "speaker"}
|
||||
size={14}
|
||||
style={{
|
||||
color: muted(i()) ? WARM : "rgba(255,255,255,0.5)"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
<p class="mt-3 min-h-8 text-center text-[11px] text-white/55">
|
||||
{
|
||||
[
|
||||
"Adjust each app separately",
|
||||
"Browser muted",
|
||||
"Music playing through headphones",
|
||||
"Music volume lowered to 38%"
|
||||
][step()]
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PowerNode(props: {
|
||||
label: string;
|
||||
glyph: GlyphName;
|
||||
watts: number;
|
||||
tint: string;
|
||||
}) {
|
||||
return (
|
||||
<div class="flex h-9 items-center gap-[7px]">
|
||||
<Glyph name={props.glyph} size={18} style={{ color: props.tint }} />
|
||||
<div>
|
||||
<p class="text-[9px] text-white/55">{props.label}</p>
|
||||
<p class="font-mono text-[11px] text-white">
|
||||
{props.watts.toFixed(1)} W
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** PowerPanelView: system draw, parallel power circuit, battery bar and controls. */
|
||||
export function BatteryDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
useDemoLoop(14000, [
|
||||
[0, () => setStep(0)],
|
||||
[1800, () => setStep(1)],
|
||||
[3600, () => setStep(2)],
|
||||
[6400, () => setStep(3)],
|
||||
[8400, () => setStep(4)],
|
||||
[10400, () => setStep(5)]
|
||||
]);
|
||||
const pct = () => [62, 71, 80, 80, 92, 100][step()];
|
||||
const topUp = () => step() >= 3;
|
||||
const held = () => step() === 2;
|
||||
const batteryWatts = () => (held() || pct() === 100 ? 0 : 26.4);
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Battery preview showing system draw, adapter power split between the Mac and battery, an 80 percent charge limit, and top-up mode"
|
||||
>
|
||||
<ModuleCard class="mx-auto w-full max-w-[340px]">
|
||||
<div class="space-y-3 text-left text-white">
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 font-mono text-[18px]">
|
||||
<span style={{ color: CALM }}>ϟ</span> 18.2 W
|
||||
</div>
|
||||
<p class="text-[9px] font-semibold text-white/55">System draw</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<PowerNode
|
||||
label="Adapter"
|
||||
glyph="plug"
|
||||
watts={18.2 + batteryWatts()}
|
||||
tint={CALM}
|
||||
/>
|
||||
<svg
|
||||
class="h-[82px] min-w-4 flex-1"
|
||||
viewBox="0 0 100 82"
|
||||
preserveAspectRatio="none"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M0 41H25C55 41 40 18 70 18H100"
|
||||
stroke={CALM}
|
||||
stroke-opacity="0.3"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0 41H25C55 41 40 64 70 64H100"
|
||||
stroke={BATTERY}
|
||||
stroke-opacity="0.3"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
class="nook-power-flow"
|
||||
d="M0 41H25C55 41 40 18 70 18H100"
|
||||
stroke={CALM}
|
||||
stroke-width="1.8"
|
||||
stroke-dasharray="3 18"
|
||||
/>
|
||||
<Show when={batteryWatts() > 0}>
|
||||
<path
|
||||
class="nook-power-flow"
|
||||
d="M0 41H25C55 41 40 64 70 64H100"
|
||||
stroke={BATTERY}
|
||||
stroke-width="1.8"
|
||||
stroke-dasharray="3 18"
|
||||
/>
|
||||
</Show>
|
||||
<path d="m96 15 4 3-4 3" stroke={CALM} stroke-width="1.5" />
|
||||
<path d="m96 61 4 3-4 3" stroke={BATTERY} stroke-width="1.5" />
|
||||
<Show when={held()}>
|
||||
<path
|
||||
d="m81 60 8 8m0-8-8 8"
|
||||
stroke={BATTERY}
|
||||
stroke-width="1.8"
|
||||
/>
|
||||
</Show>
|
||||
</svg>
|
||||
<div class="space-y-2.5">
|
||||
<PowerNode label="Mac" glyph="laptop" watts={18.2} tint={CALM} />
|
||||
<PowerNode
|
||||
label="Battery"
|
||||
glyph="battery"
|
||||
watts={batteryWatts()}
|
||||
tint={BATTERY}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="flex-1 text-[9px] font-semibold text-white/55 uppercase">
|
||||
Battery
|
||||
</span>
|
||||
<span class="font-mono text-[15px]">{pct()}%</span>
|
||||
<span class="ml-2 flex items-center gap-1 font-mono text-[10px] text-white/70">
|
||||
Limit{" "}
|
||||
<span class="rounded border border-white/15 bg-white/5 px-2 py-0.5 text-[12px] text-white/90">
|
||||
80
|
||||
</span>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative flex h-[18px] items-center">
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-white/[0.08]">
|
||||
<div
|
||||
class="h-full rounded-full transition-[width] duration-[1600ms]"
|
||||
style={{ width: `${pct()}%`, background: CALM }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="absolute h-3 w-1 rounded-sm bg-white/85"
|
||||
style={{ left: "calc(80% - 2px)" }}
|
||||
/>
|
||||
</div>
|
||||
<For
|
||||
each={[
|
||||
{
|
||||
label: "Top-up mode",
|
||||
note: "Charges past the limit; off on unplug",
|
||||
on: topUp()
|
||||
},
|
||||
{
|
||||
label: "Overheat protection",
|
||||
note: topUp()
|
||||
? "Suspended while Top-up mode is on"
|
||||
: "Pauses at 95°F",
|
||||
on: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
{(control) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[12px] font-medium">{control.label}</p>
|
||||
<p class="text-[10px] leading-snug text-white/55">
|
||||
{control.note}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="relative h-4 w-[30px] shrink-0 rounded-full transition-colors"
|
||||
style={{
|
||||
background: control.on ? CALM : "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 h-3 w-3 rounded-full bg-white transition-[left]"
|
||||
style={{ left: control.on ? "16px" : "2px" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
<p class="mt-3 min-h-8 text-center text-[11px] text-white/55">
|
||||
{held()
|
||||
? "Charging paused at the 80% limit"
|
||||
: topUp()
|
||||
? "Top-up mode allows a full charge"
|
||||
: "Charging with an 80% limit"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DAYS = [
|
||||
{ letter: "M", day: 9, dots: 1 },
|
||||
{ letter: "T", day: 10, dots: 2 },
|
||||
{ letter: "W", day: 11, dots: 1 },
|
||||
{ letter: "T", day: 12, dots: 2 },
|
||||
{ letter: "F", day: 13, dots: 3 },
|
||||
{ letter: "S", day: 14, dots: 0 },
|
||||
{ letter: "S", day: 15, dots: 0 }
|
||||
];
|
||||
const EVENTS = [
|
||||
{
|
||||
start: "09:30",
|
||||
end: "09:45",
|
||||
title: "Standup",
|
||||
calendar: "Work",
|
||||
color: CALENDAR,
|
||||
location: "Video call",
|
||||
meeting: true
|
||||
},
|
||||
{
|
||||
start: "15:30",
|
||||
end: "16:00",
|
||||
title: "Design review",
|
||||
calendar: "Work",
|
||||
color: CALENDAR,
|
||||
location: "4th floor",
|
||||
meeting: false
|
||||
}
|
||||
];
|
||||
|
||||
/** CalendarSlotFace's daymark / next-up / on-air states, and CalendarPanelView. */
|
||||
export function CalendarDemo() {
|
||||
const [step, setStep] = createSignal(0);
|
||||
useDemoLoop(16000, [
|
||||
[0, () => setStep(0)],
|
||||
[2400, () => setStep(1)],
|
||||
[4800, () => setStep(2)],
|
||||
[9000, () => setStep(3)],
|
||||
[12000, () => setStep(4)]
|
||||
]);
|
||||
const onAir = () => step() >= 3;
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Calendar preview showing the date, a meeting countdown, an on-air indicator, and the day's events with a join meeting button"
|
||||
class="flex min-h-[360px] min-w-0 flex-col items-center gap-4"
|
||||
>
|
||||
<div class="flex max-w-full justify-center" style={{ zoom: 0.8 }}>
|
||||
<IslandPill
|
||||
right={
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<Show when={step() === 0}>
|
||||
<rect
|
||||
x="6"
|
||||
y="4"
|
||||
width="28"
|
||||
height="27"
|
||||
rx="5"
|
||||
fill="#ededed"
|
||||
/>
|
||||
<path
|
||||
d="M11 4h18a5 5 0 0 1 5 5v3H6V9a5 5 0 0 1 5-5"
|
||||
fill={CALENDAR}
|
||||
/>
|
||||
<text
|
||||
x="20"
|
||||
y="10"
|
||||
text-anchor="middle"
|
||||
font-size="5.5"
|
||||
font-weight="700"
|
||||
fill="#1f1f1f"
|
||||
>
|
||||
THU
|
||||
</text>
|
||||
<text
|
||||
x="20"
|
||||
y="28"
|
||||
text-anchor="middle"
|
||||
font-size="17"
|
||||
font-weight="700"
|
||||
fill="#1f1f1f"
|
||||
>
|
||||
12
|
||||
</text>
|
||||
<circle cx="20" cy="36" r="2.25" fill={CALENDAR} />
|
||||
</Show>
|
||||
<Show when={step() === 1 || step() === 2}>
|
||||
<text
|
||||
x="20"
|
||||
y="10"
|
||||
text-anchor="middle"
|
||||
font-size="7"
|
||||
font-weight="700"
|
||||
fill="white"
|
||||
opacity="0.65"
|
||||
>
|
||||
IN
|
||||
</text>
|
||||
<text
|
||||
x="20"
|
||||
y="30"
|
||||
text-anchor="middle"
|
||||
font-size="20"
|
||||
font-weight="700"
|
||||
fill="white"
|
||||
>
|
||||
{step() === 1 ? "14m" : "5m"}
|
||||
</text>
|
||||
<rect
|
||||
x="6"
|
||||
y="34"
|
||||
width="28"
|
||||
height="2"
|
||||
rx="1"
|
||||
fill="white"
|
||||
opacity="0.15"
|
||||
/>
|
||||
<rect
|
||||
x="6"
|
||||
y="34"
|
||||
width={step() === 1 ? 2 : 19}
|
||||
height="2"
|
||||
rx="1"
|
||||
fill={CALENDAR}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={onAir()}>
|
||||
<rect
|
||||
x="1"
|
||||
y="3"
|
||||
width="38"
|
||||
height="22"
|
||||
rx="5"
|
||||
fill={CALENDAR}
|
||||
opacity="0.18"
|
||||
/>
|
||||
<circle cx="4.7" cy="14.5" r="2.1" fill={CALENDAR} />
|
||||
<text
|
||||
x="22.6"
|
||||
y="17"
|
||||
text-anchor="middle"
|
||||
font-size="7.8"
|
||||
font-weight="700"
|
||||
fill={CALENDAR}
|
||||
>
|
||||
ON AIR
|
||||
</text>
|
||||
<rect
|
||||
x="1"
|
||||
y="31"
|
||||
width="38"
|
||||
height="5"
|
||||
rx="2.5"
|
||||
fill={CALENDAR}
|
||||
opacity="0.7"
|
||||
/>
|
||||
<rect
|
||||
x="1"
|
||||
y="31"
|
||||
width={step() === 4 ? 25 : 8}
|
||||
height="5"
|
||||
rx="2.5"
|
||||
fill="#333"
|
||||
/>
|
||||
<rect
|
||||
x={step() === 4 ? 25 : 8}
|
||||
y="29"
|
||||
width="2.6"
|
||||
height="9"
|
||||
rx="1.3"
|
||||
fill="#ededed"
|
||||
/>
|
||||
</Show>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full max-w-[360px]">
|
||||
<ModuleCard>
|
||||
<div class="space-y-2.5 text-left text-white">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-[10px] text-white/55">Calendar</p>
|
||||
<p class="text-[14px] font-semibold">Thu, Nov 12</p>
|
||||
</div>
|
||||
<span class="ml-auto rounded-lg bg-white/[0.08] p-[7px] text-white/55">
|
||||
<Glyph name="plus" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-[60px] gap-[3px]">
|
||||
<For each={DAYS}>
|
||||
{(day) => (
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col items-center justify-center gap-[5px] rounded-[10px]"
|
||||
style={{
|
||||
background: day.day === 12 ? CALENDAR : "transparent",
|
||||
color: day.day === 12 ? "#0d0d0f" : "white",
|
||||
opacity: day.day < 12 ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
<span class="text-[9px] opacity-60">{day.letter}</span>
|
||||
<span
|
||||
class="text-[13px]"
|
||||
style={{ "font-weight": day.day === 12 ? 700 : 500 }}
|
||||
>
|
||||
{day.day}
|
||||
</span>
|
||||
<span class="flex h-1 gap-0.5">
|
||||
<For each={Array.from({ length: day.dots })}>
|
||||
{() => (
|
||||
<span
|
||||
class="h-1 w-1 rounded-full"
|
||||
style={{ background: CALENDAR }}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<For each={EVENTS}>
|
||||
{(event) => (
|
||||
<div class="flex items-center gap-2.5 rounded-[10px] bg-white/[0.035] p-2.5">
|
||||
<div class="w-[40px] shrink-0 text-right font-mono text-[10px]">
|
||||
<p>{event.start}</p>
|
||||
<p class="mt-[3px] text-white/55">{event.end}</p>
|
||||
</div>
|
||||
<span
|
||||
class="w-[3px] self-stretch rounded-sm"
|
||||
style={{ background: event.color }}
|
||||
/>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<p class="text-[12px] leading-snug font-semibold">
|
||||
{event.title}
|
||||
</p>
|
||||
<p
|
||||
class="text-[10px] font-medium"
|
||||
style={{ color: event.color }}
|
||||
>
|
||||
{event.calendar}
|
||||
</p>
|
||||
<p class="text-[10px] text-white/55">{event.location}</p>
|
||||
</div>
|
||||
<Show when={event.meeting}>
|
||||
<span
|
||||
aria-label="Join meeting"
|
||||
class="rounded-lg bg-white/[0.08] p-2 text-white/65"
|
||||
>
|
||||
<Glyph name="video" />
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
</div>
|
||||
<p class="min-h-4 text-center text-[11px] text-white/55">
|
||||
{step() === 0
|
||||
? "Today's date and calendar events"
|
||||
: onAir()
|
||||
? "Meeting in progress"
|
||||
: "Countdown to your next meeting"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,8 +67,7 @@ describe("generateSitemap", () => {
|
||||
expect(locs).toContain("https://nessa.freno.me/");
|
||||
expect(locs).toContain("https://nessa.freno.me/contact");
|
||||
expect(locs).toContain("https://nessa.freno.me/privacy");
|
||||
expect(locs).toContain("https://nessa.freno.me/terms");
|
||||
expect(locs.length).toBe(4);
|
||||
expect(locs.length).toBe(3);
|
||||
|
||||
for (const loc of locs) {
|
||||
expect(loc).not.toContain("://freno.me/");
|
||||
|
||||
@@ -57,8 +57,7 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
||||
nessa: [
|
||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 },
|
||||
{ path: "/terms", changefreq: "yearly", priority: 0.4 }
|
||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
||||
],
|
||||
|
||||
lineage: [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ContactForm } from "~/components/ContactForm";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
/**
|
||||
* Nessa contact page (`nessa.freno.me/contact`).
|
||||
@@ -17,27 +16,14 @@ import { SUPPORT_EMAIL } from "./content";
|
||||
*
|
||||
* Acceptance: `nessa.localhost:3000/contact` renders the contact form with
|
||||
* Nessa branding; submissions email `michael@freno.me` with subject
|
||||
* `[Nessa] Contact Request`. The support address shown by the app is also
|
||||
* published here, so the contact route does not depend on the form alone
|
||||
* (FRE-1670).
|
||||
* `[Nessa] Contact Request`.
|
||||
*/
|
||||
export default function NessaContactPage() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
|
||||
<SubdomainHeader />
|
||||
<ContactForm>
|
||||
<p class="mt-8 text-center text-sm opacity-80">
|
||||
You can also email us directly at{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</ContactForm>
|
||||
<ContactForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,15 +14,6 @@ export const TAGLINE = "The fitness app that puts you first." as const;
|
||||
export const SUBTITLE =
|
||||
"Track, train, and connect — without the paywall." as const;
|
||||
|
||||
/**
|
||||
* The support address the Nessa app shows (`SubscriptionView` /
|
||||
* `HelpSupportView`). Every published Nessa document that offers a contact
|
||||
* route names this same address, so a user who leaves the app to read the
|
||||
* terms, privacy policy, contact page, or deletion page can still reach us
|
||||
* (FRE-1670). Keep in step with `nessa-support@freno.me` in the app.
|
||||
*/
|
||||
export const SUPPORT_EMAIL = "nessa-support@freno.me" as const;
|
||||
|
||||
export const ICON_DEFAULT =
|
||||
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
|
||||
export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const;
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
DELETION_GRACE_PERIOD_LABEL,
|
||||
PAGE_META
|
||||
} from "~/routes/nessa/deletion-content";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
export default function NessaDeletionPage() {
|
||||
return (
|
||||
@@ -55,18 +54,6 @@ export default function NessaDeletionPage() {
|
||||
removed. No data related to the account is retained in any way.
|
||||
</div>
|
||||
|
||||
<div class="text-text mt-4 w-full justify-center">
|
||||
Prefer email? Send your deletion request from the address on your
|
||||
Nessa account to{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}?subject=Account%20Deletion%20Request`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
; the same {DELETION_GRACE_PERIOD_LABEL} grace period applies.
|
||||
</div>
|
||||
|
||||
<DeletionForm
|
||||
product={DELETION_PRODUCT_KEY}
|
||||
cookieName={DELETION_COOKIE_NAME}
|
||||
|
||||
@@ -30,7 +30,6 @@ import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
export default function NessaPrivacyPolicy() {
|
||||
return (
|
||||
@@ -175,29 +174,15 @@ export default function NessaPrivacyPolicy() {
|
||||
<span class="-ml-4 pr-2">5.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
<div class="-ml-6">(a) Data Controller:</div> Nessa is operated by
|
||||
FrenoCorp. For the purposes of applicable data-protection law,
|
||||
FrenoCorp is the controller of the personal information described
|
||||
in this policy.
|
||||
<div class="pt-2 pb-2">
|
||||
<div class="-ml-6">(b) Reaching Out:</div> If there are any
|
||||
questions or comments regarding this privacy policy, or you want
|
||||
to access, correct, or delete your personal information, you can
|
||||
<div class="-ml-6">(a) Reaching Out:</div> If there are any
|
||||
questions or comments regarding this privacy policy, you can
|
||||
contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
through the contact form
|
||||
</A>{" "}
|
||||
or email{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Regression guard for the published Nessa support address (FRE-1670).
|
||||
*
|
||||
* The app tells users to mail `nessa-support@freno.me` from the subscription
|
||||
* screen and Help & Support. Before this guard, no published Nessa page named
|
||||
* that address: a user who left the app to read the terms, privacy policy,
|
||||
* contact page, or deletion page found no way to reach support. That is a
|
||||
* privacy-policy contact gap (GDPR/CCPA-style) and an App Review question
|
||||
* waiting to happen.
|
||||
*
|
||||
* The address lives in one place (`SUPPORT_EMAIL`), and every page that
|
||||
* offers a contact route must render it. Reading the route sources is
|
||||
* deliberate: the defect is a page that exists but does not mention the
|
||||
* address, which no runtime assertion over a single page can observe.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readFileSync } from "fs";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
const CONTACT_ROUTE_FILES = [
|
||||
"src/routes/nessa/terms.tsx",
|
||||
"src/routes/nessa/privacy.tsx",
|
||||
"src/routes/nessa/contact.tsx",
|
||||
"src/routes/nessa/deletion.tsx"
|
||||
];
|
||||
|
||||
describe("Nessa published support address", () => {
|
||||
it("is the address the app shows", () => {
|
||||
expect(SUPPORT_EMAIL).toBe("nessa-support@freno.me");
|
||||
});
|
||||
|
||||
it.each(CONTACT_ROUTE_FILES)("%s renders it", (file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
expect(source).toContain("SUPPORT_EMAIL");
|
||||
});
|
||||
});
|
||||
@@ -1,454 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
import { SUPPORT_EMAIL } from "./content";
|
||||
|
||||
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 9, 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">
|
||||
through the contact form
|
||||
</A>{" "}
|
||||
or email{" "}
|
||||
<a
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -59,9 +59,9 @@ export default function NookLanding() {
|
||||
<PageHead
|
||||
title="Agent control for your Mac"
|
||||
ogTitle="The Nook — agent control for your Mac"
|
||||
description="Monitor coding agents, respond to permission requests, and manage your Mac\'s cooling, battery, and audio with The Nook."
|
||||
description="The Nook — a native macOS utility for coding-agent orchestration, fan and thermal control."
|
||||
ogImage={`https://${site().domain}/nook/og.png`}
|
||||
ogDescription="A macOS app for coding agents, fan control, battery charging, and per-app audio. Includes a 14-day trial and a one-time license."
|
||||
ogDescription="A one-time-purchase macOS app for coding-agent orchestration, fan control, and thermal insight."
|
||||
/>
|
||||
|
||||
<SubdomainHeader />
|
||||
@@ -96,11 +96,11 @@ export default function NookLanding() {
|
||||
The Nook
|
||||
</div>
|
||||
<h1 class="text-text mb-4 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Manage your coding agents and your Mac
|
||||
Your Mac and Agents, under control
|
||||
</h1>
|
||||
<p class="text-subtext0 mb-2 max-w-xl text-xl">
|
||||
Follow your agents, respond to requests, and manage cooling,
|
||||
charging, and audio from the top of your screen.
|
||||
Agent orchestration, fan and thermal control in a beautiful native
|
||||
UI.
|
||||
</p>
|
||||
<p class="text-subtext1 mb-8 text-sm">
|
||||
macOS 14.2+ · 14-day free trial · up to 3 devices
|
||||
@@ -145,7 +145,7 @@ export default function NookLanding() {
|
||||
In action
|
||||
</p>
|
||||
<h2 class="text-text mb-16 text-center text-3xl font-bold">
|
||||
What you can do with The Nook
|
||||
Built for the way you actually run agents
|
||||
</h2>
|
||||
<FeatureBreakdowns />
|
||||
</div>
|
||||
@@ -164,9 +164,10 @@ export default function NookLanding() {
|
||||
Try it free for 14 days
|
||||
</h2>
|
||||
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
|
||||
The trial includes every module. After 14 days, a one-time
|
||||
purchase lets you keep using The Nook on up to three of your Macs.
|
||||
There is no subscription.
|
||||
Orchestrate your coding agents, take control of your fans and
|
||||
battery, give every app its own volume, capture your screen, and
|
||||
keep your day in the notch. When the trial ends, unlock everything
|
||||
with a single one-time payment — no subscription, ever.
|
||||
</p>
|
||||
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
|
||||
<Button
|
||||
@@ -186,7 +187,7 @@ export default function NookLanding() {
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-subtext1 mt-6 text-xs">
|
||||
The beta price is $10. The regular price will be $15. One license covers up to 3 of
|
||||
Beta pricing — full price will be $15. One license covers up to 3 of
|
||||
your own Macs.
|
||||
</p>
|
||||
</div>
|
||||
@@ -228,7 +229,8 @@ export default function NookLanding() {
|
||||
</form>
|
||||
<Show when={resendSent()}>
|
||||
<p class="text-subtext1 mt-4 text-center text-sm">
|
||||
If a license is registered to that email, we'll send you the key.
|
||||
Check your inbox — if a license is registered to that email, your
|
||||
key is on its way.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={resendError()}>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = join(import.meta.dir, "../../..");
|
||||
const read = (path: string) => readFileSync(join(root, path), "utf8");
|
||||
const landingCopy = [
|
||||
"src/routes/nook/index.tsx",
|
||||
...[
|
||||
"FeatureCollage",
|
||||
"FeatureBreakdowns",
|
||||
"IslandExtras",
|
||||
"PhoneBeta",
|
||||
"MoreDemos",
|
||||
"UtilityDemos"
|
||||
].map((name) => `src/components/nook/${name}.tsx`)
|
||||
]
|
||||
.map(read)
|
||||
.join("\n");
|
||||
|
||||
// Like remote-claims.test.ts, this pins an editorial decision, not a tone score.
|
||||
describe("Nook landing copy (NOOK-1037)", () => {
|
||||
for (const phrase of [
|
||||
"Your fleet, in your pocket",
|
||||
"Timers that stack",
|
||||
"Volume and brightness, under the notch",
|
||||
"The sky, in the slot",
|
||||
"Storage, measured",
|
||||
"Wall power, on your terms",
|
||||
"Fans that answer to you, not the scheduler",
|
||||
"Your whole fleet, in one pixel strip",
|
||||
"A hearth that tells the truth",
|
||||
"A lens that stays asleep",
|
||||
"Your buttons, orbiting the island",
|
||||
"Silence it or max it",
|
||||
"Sleep when you say",
|
||||
"Panels you shape"
|
||||
]) {
|
||||
it(`replaces “${phrase}”`, () => {
|
||||
expect(landingCopy).not.toContain(phrase);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Source anatomy checks supplement the rendered browser checks.
|
||||
const demos = read("src/components/nook/UtilityDemos.tsx");
|
||||
|
||||
describe("current utility demo anatomy (NOOK-1037)", () => {
|
||||
it("shows the three calendar slot states and calendar-colored agenda rows", () => {
|
||||
for (const text of ["ON AIR", "Work", "Thu, Nov 12", "Join meeting"])
|
||||
expect(demos).toContain(text);
|
||||
expect(demos).toMatch(/>\s*IN\s*</);
|
||||
});
|
||||
|
||||
it("shows the power circuit and current battery controls", () => {
|
||||
for (const text of [
|
||||
"System draw",
|
||||
"Adapter",
|
||||
"Mac",
|
||||
"Overheat protection",
|
||||
"Charges past the limit; off on unplug"
|
||||
])
|
||||
expect(demos).toContain(text);
|
||||
expect(demos).not.toContain("the pack stays young longer");
|
||||
});
|
||||
|
||||
it("shows master routing, app percentages, pin and hide controls", () => {
|
||||
for (const text of [
|
||||
"System sounds",
|
||||
"Microphone",
|
||||
"Output volume",
|
||||
"Pin app",
|
||||
"Hide app",
|
||||
"Default"
|
||||
])
|
||||
expect(demos).toContain(text);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,39 @@
|
||||
/**
|
||||
* NOOK-1038 restores remote marketing for Tailscale and authorized known hosts,
|
||||
* per Mike's correction. FRE-323's blanket removal is no longer the contract;
|
||||
* one-click setup is a supported scoped claim, not a promise for any SSH box.
|
||||
* FRE-359's separate decision against agent coverage counts is unchanged.
|
||||
* Static source audit, like agents.test.ts; browser checks cover rendered layout.
|
||||
* Regression test for the removed remote / SSH claims and the removed agent
|
||||
* count (FRE-323, FRE-359 verdict B).
|
||||
*
|
||||
* FRE-323 took the remote story off the site because it was unverified and
|
||||
* demo-gated (FRE-315, FRE-320, FRE-322 §3 correction 4): the "one click
|
||||
* installs the nook-hook" 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-359 verdict B removed the coverage count for the same
|
||||
* reason: the /agents table lists 13 of the registry's 18 rows.
|
||||
*
|
||||
* The copy was not thrown away — it is parked in
|
||||
* `marketing/drafts/remote-ssh/` with the restore gate, and this test asserts
|
||||
* it is still there so nobody deletes the parked story as stale. It goes back
|
||||
* on the site only when the one-click install ships and is recorded.
|
||||
*
|
||||
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
|
||||
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
|
||||
*
|
||||
* Deliberate exceptions, not audited here — both were weighed with the Chief
|
||||
* of Staff at the go/no-go and left in place on purpose:
|
||||
* - `src/routes/nook/agents.tsx` Muse row: "This Mac only — remote hosts skip
|
||||
* it" is a limitation disclosure, not a capability claim.
|
||||
* - `src/components/nook/IslandExtras.tsx` "Tailscale hosts" chip: adjacent to
|
||||
* the removed remote story, but tailnet host lookup does exist. Raised on
|
||||
* FRE-323 rather than widened into this correction.
|
||||
*/
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "../../..");
|
||||
|
||||
/** Shipped page and component source. Tests and drafts may quote the old copy. */
|
||||
const AUDITED = [
|
||||
"src/routes/nook/index.tsx",
|
||||
"src/routes/nook/agents.tsx",
|
||||
@@ -19,74 +43,58 @@ const AUDITED = [
|
||||
"src/components/nook/IslandExtras.tsx"
|
||||
];
|
||||
|
||||
/** Every sentence FRE-323 ordered off the site. */
|
||||
const BANNED_REMOTE_CLAIMS = [
|
||||
"Agents on other machines",
|
||||
"one click installs the nook-hook",
|
||||
"Any SSH box",
|
||||
"Works across every hooked agent, local or remote",
|
||||
"Remote sessions are ordinary rows",
|
||||
"One-click SSH setup",
|
||||
"remote machines over SSH",
|
||||
"this Mac or remote machines"
|
||||
];
|
||||
|
||||
/** FRE-359 verdict B: no count, no coverage claim, in any form. */
|
||||
const BANNED_COVERAGE = [
|
||||
"all thirteen",
|
||||
"thirteen coding agents",
|
||||
"13 agents",
|
||||
"every coding agent",
|
||||
"Supported agents"
|
||||
];
|
||||
|
||||
/** User-facing copy only: comments are not copy and may name the old wording. */
|
||||
function copyOf(file: string): string {
|
||||
return readFileSync(join(ROOT, file), "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
describe("remote marketing reflects the supported setup (NOOK-1038)", () => {
|
||||
it("fills the collage's missing sixth grid slot with host-labelled remote sessions", () => {
|
||||
const collage = copyOf("src/components/nook/FeatureCollage.tsx");
|
||||
// The hero spans two columns; four regular cards fill the other slots.
|
||||
expect(collage.match(/<CollageCell\b/g)).toHaveLength(4);
|
||||
expect(collage).toContain('kicker="Remote"');
|
||||
expect(collage).toContain(
|
||||
"One-click setup for Tailscale and authorized known hosts"
|
||||
);
|
||||
expect(collage).toContain('host="build-box"');
|
||||
expect(collage).toContain('host="gpu-rig"');
|
||||
});
|
||||
|
||||
it("restores the detailed setup section with both discovery sources", () => {
|
||||
const breakdowns = copyOf("src/components/nook/FeatureBreakdowns.tsx");
|
||||
expect(breakdowns).toContain('kicker="Remote"');
|
||||
expect(breakdowns).toContain('title="Set up remote agents in one click"');
|
||||
expect(breakdowns).toContain(
|
||||
"Tailscale peer or an authorized host from your SSH config"
|
||||
);
|
||||
expect(breakdowns).toContain("<RemoteHostDemo />");
|
||||
});
|
||||
|
||||
it("demonstrates setup progressing to Live and a host-labelled session", () => {
|
||||
const demos = copyOf("src/components/nook/MoreDemos.tsx");
|
||||
for (const label of [
|
||||
"connecting",
|
||||
"pushing the hook binary",
|
||||
"configuring agents",
|
||||
"checking the host can reach this Mac",
|
||||
"Live",
|
||||
"Set up"
|
||||
])
|
||||
expect(demos).toContain(label);
|
||||
expect(demos).toContain('host="gpu-rig"');
|
||||
});
|
||||
|
||||
describe("remote claims stay off the site (FRE-323)", () => {
|
||||
for (const file of AUDITED) {
|
||||
it(`${file} makes no blanket authorization or agent-coverage promise`, () => {
|
||||
const copy = copyOf(file).toLowerCase();
|
||||
for (const phrase of [
|
||||
"Any SSH box",
|
||||
"Works across every hooked agent, local or remote",
|
||||
"no SSH authorization needed"
|
||||
])
|
||||
expect(copy).not.toContain(phrase.toLowerCase());
|
||||
it(`${file} does not restore a removed remote sentence`, () => {
|
||||
const copy = copyOf(file);
|
||||
for (const phrase of BANNED_REMOTE_CLAIMS) {
|
||||
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("records the current decision without deleting the historical copy", () => {
|
||||
for (const file of [
|
||||
"marketing/drafts/remote-ssh/copy.md",
|
||||
"marketing/drafts/remote-ssh/rationale.md",
|
||||
"marketing/positioning.md"
|
||||
]) {
|
||||
const text = readFileSync(join(ROOT, file), "utf8");
|
||||
expect(text).toContain("NOOK-1038");
|
||||
expect(text).toContain("Tailscale and authorized known hosts");
|
||||
it("/agents no longer offers remote sessions", () => {
|
||||
const copy = copyOf("src/routes/nook/agents.tsx");
|
||||
expect(copy).not.toContain("remote sessions report into the same grid");
|
||||
expect(copy).not.toContain("on this Mac or on remote");
|
||||
});
|
||||
|
||||
it("the parked copy still exists, so restoring the story is a revert and not a rewrite", () => {
|
||||
const parked = readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8");
|
||||
for (const phrase of ["Agents on other machines", "Any SSH box, one click away"]) {
|
||||
expect(parked).toContain(phrase);
|
||||
}
|
||||
expect(
|
||||
readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8")
|
||||
).toContain("Any SSH box, one click away");
|
||||
expect(readFileSync(join(ROOT, "marketing/drafts/remote-ssh/rationale.md"), "utf8")).toContain(
|
||||
"FRE-320"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,14 +102,9 @@ describe("agent coverage count stays off the page (FRE-359 verdict B)", () => {
|
||||
for (const file of AUDITED) {
|
||||
it(`${file} states no coverage count`, () => {
|
||||
const copy = copyOf(file);
|
||||
for (const phrase of [
|
||||
"all thirteen",
|
||||
"thirteen coding agents",
|
||||
"13 agents",
|
||||
"every coding agent",
|
||||
"Supported agents"
|
||||
])
|
||||
for (const phrase of BANNED_COVERAGE) {
|
||||
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* A Smarter Remote privacy policy — `freno.me/smarter-remote/privacy`.
|
||||
*
|
||||
* Linked from the iOS app's paywall (App Review 3.1.2). Reflects what the app
|
||||
* actually does: TV control stays on the local network, pairing data stays on
|
||||
* the phone, purchases go through Apple and RevenueCat, and sharing is a QR
|
||||
* code passed between phones.
|
||||
*/
|
||||
import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
|
||||
export default function SmarterRemotePrivacyPolicy() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="A Smarter Remote Privacy Policy"
|
||||
description="Privacy policy for A Smarter Remote, a universal TV remote app that works over your local Wi-Fi network."
|
||||
/>
|
||||
<div class="bg-base">
|
||||
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<div class="py-4 text-xl">A Smarter Remote's Privacy Policy</div>
|
||||
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||
<div class="py-2">
|
||||
Welcome to A Smarter Remote ('We', 'Us',
|
||||
'Our'). The app controls your TVs over your own Wi-Fi
|
||||
network. It has no accounts, no ads, and no analytics. This policy
|
||||
explains the little data it does handle.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) Your TVs:</div> The app finds TVs on
|
||||
your local network and stores their names, network addresses,
|
||||
and platform on your phone. Pairing codes and tokens are kept
|
||||
in your phone's Keychain. This data is sent only to the
|
||||
TV itself, over your local network. It never reaches us.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Purchases:</div> Payments are handled
|
||||
by Apple. We use RevenueCat to check whether you own A Smarter
|
||||
Remote Pro. RevenueCat receives an anonymous app user ID, your
|
||||
purchase and subscription history, and basic device and app
|
||||
details such as OS version, app version, and region. It does
|
||||
not receive your name, email, or payment details.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Voice input:</div> If you use voice
|
||||
search, your speech is converted to text by Apple's
|
||||
speech recognition. Apple may process it on its servers. The
|
||||
resulting text is sent only to your TV.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Sharing:</div> If you share a TV with
|
||||
someone, its name, address, and platform are put into a QR
|
||||
code on your screen for them to scan. It goes from one phone
|
||||
to the other and does not pass through our servers.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
Data about your TVs is used only to control them. Purchase
|
||||
records are used only to unlock Pro. We do not use any of it for
|
||||
advertising, and we do not sell it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">3.</span> Local Network Access
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
iOS asks for permission before the app can use your local
|
||||
network. The app needs it to find and control your TVs. If you
|
||||
decline, the app cannot reach your TVs. You can change this in
|
||||
Settings > Privacy & Security > Local Network.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">4.</span> Deleting Your Data
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
Removing a TV in the app deletes its stored details and
|
||||
credentials from your phone. Deleting the app removes all of
|
||||
them. Subscriptions are managed and cancelled in your App Store
|
||||
account settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">5.</span> Children's Privacy
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
The app does not knowingly collect personal information from
|
||||
anyone, including children under 13.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">6.</span> Changes to the Privacy Policy
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
We may update this policy periodically. Any changes are posted on
|
||||
this page, so please review it from time to time.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">7.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
If you have any questions about this policy, you can contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* A Smarter Remote terms of use — `freno.me/smarter-remote/terms`.
|
||||
*
|
||||
* Linked from the iOS app's paywall (App Review 3.1.2). Builds on Apple's
|
||||
* standard EULA and adds the Pro subscription and lifetime purchase terms.
|
||||
*/
|
||||
import { A } from "@solidjs/router";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||
|
||||
export default function SmarterRemoteTerms() {
|
||||
return (
|
||||
<>
|
||||
<EdgeCacheHeaders maxAge={300} />
|
||||
<PageHead
|
||||
title="A Smarter Remote Terms of Use"
|
||||
description="Terms of use for A Smarter Remote and its Pro subscription."
|
||||
/>
|
||||
<div class="bg-base">
|
||||
<div class="min-h-screen px-[8vw] py-[8vh]">
|
||||
<div class="py-4 text-xl">A Smarter Remote's Terms of Use</div>
|
||||
<div class="py-2">Last Updated: November 2, 2026</div>
|
||||
<div class="py-2">
|
||||
These terms cover your use of the A Smarter Remote app ('the
|
||||
App'). By using the App you agree to them. The App is licensed
|
||||
to you under Apple's{" "}
|
||||
<a
|
||||
href="https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
Licensed Application End User License Agreement
|
||||
</a>
|
||||
, and the terms below add to it.
|
||||
</div>
|
||||
<ol>
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">1.</span> A Smarter Remote Pro
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(a) What it includes:</div> The free App
|
||||
controls one TV. Pro removes that limit and unlocks the
|
||||
features marked Pro in the App.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(b) Yearly subscription:</div> Payment is
|
||||
charged to your Apple account when you confirm the purchase,
|
||||
or when any free trial ends. The subscription renews
|
||||
automatically at the same price unless you cancel at least 24
|
||||
hours before the end of the current period. Your account is
|
||||
charged for renewal within 24 hours before the period ends.
|
||||
Any unused part of a free trial is forfeited when you buy a
|
||||
subscription.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(c) Lifetime purchase:</div> A one-time
|
||||
payment that unlocks Pro with no recurring charge.
|
||||
</div>
|
||||
<div class="pb-2">
|
||||
<div class="-ml-6">(d) Managing and refunds:</div> You can
|
||||
manage or cancel a subscription in your App Store account
|
||||
settings. Refunds are handled by Apple under its policies.
|
||||
Restore Purchases in the App re-unlocks Pro on another device
|
||||
signed in to the same Apple account.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">2.</span> Your TVs
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
The App controls TVs through the network features their makers
|
||||
provide. Which features work depends on your TV's model and
|
||||
software, which can change without notice. We do not guarantee
|
||||
that every feature works on every TV. Only control TVs you own
|
||||
or have permission to use.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">3.</span> No Warranty
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
The App is provided 'as is', without warranties of any
|
||||
kind, to the extent the law allows. To the extent the law allows,
|
||||
we are not liable for indirect or consequential damages arising
|
||||
from your use of the App.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">4.</span> Privacy
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
How the App handles data is described in the{" "}
|
||||
<A
|
||||
href="/smarter-remote/privacy"
|
||||
class="text-blue hover-underline-animation"
|
||||
>
|
||||
Privacy Policy
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">5.</span> Changes to These Terms
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
We may update these terms periodically. Any changes are posted on
|
||||
this page. Continuing to use the App after a change means you
|
||||
accept the new terms.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-2">
|
||||
<div class="pb-2 text-lg">
|
||||
<span class="-ml-4 pr-2">6.</span> Contact Us
|
||||
</div>
|
||||
<div class="pb-2 pl-4">
|
||||
If you have any questions about these terms, you can contact us{" "}
|
||||
<A href="/contact" class="text-blue hover-underline-animation">
|
||||
here
|
||||
</A>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,7 @@ import { describe, it, expect } from "bun:test";
|
||||
import { readdirSync, existsSync, statSync } from "fs";
|
||||
|
||||
function isRouteFile(name: string): boolean {
|
||||
// Page routes are components; the `.ts` files beside them are pure content /
|
||||
// metadata modules (content.ts, meta.ts, *-content.ts) with no default
|
||||
// export the router can render, so they are not dispatch targets.
|
||||
return name.endsWith(".tsx");
|
||||
return name.endsWith(".tsx") || name.endsWith(".ts");
|
||||
}
|
||||
|
||||
function listRouteFiles(dir: string): string[] {
|
||||
@@ -28,20 +25,6 @@ function listRouteFiles(dir: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function listDirectories(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir).filter((name) => {
|
||||
try {
|
||||
return statSync(`${dir}/${name}`).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function hasDispatchRoute(routeName: string, routesDir: string): boolean {
|
||||
// Check for src/routes/<name>.tsx
|
||||
if (existsSync(`${routesDir}/${routeName}.tsx`)) {
|
||||
@@ -58,12 +41,14 @@ describe("subdomain routing dispatch invariant", () => {
|
||||
const routesDir = "./src/routes";
|
||||
|
||||
it("every subdomain route has a top-level dispatch route", () => {
|
||||
// List directory entries before filtering to route files. The previous
|
||||
// shape filtered with `listRouteFiles` first, which only ever returns
|
||||
// `.tsx`/`.ts` names, so `statSync(...).isDirectory()` was never true
|
||||
// and the assertion ran over an empty list — it passed while `/terms`
|
||||
// had no dispatch file (FRE-1670).
|
||||
const prefixDirs = listDirectories(routesDir)
|
||||
const prefixDirs = listRouteFiles(routesDir)
|
||||
.filter((name) => {
|
||||
try {
|
||||
return statSync(`${routesDir}/${name}`).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
// Exclude non-product subdomains
|
||||
.filter((name) =>
|
||||
["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Switch, Match } from "solid-js";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import NotFound from "./[...404]";
|
||||
import NessaTermsOfUse from "./nessa/terms";
|
||||
|
||||
/**
|
||||
* Host-aware terms-of-use route — `/terms`.
|
||||
*
|
||||
* The public browser path `/terms` is what the Nessa app links from
|
||||
* Settings › Legal and from the subscription paywall
|
||||
* (`AppSettings.termsOfServiceURL`). Production Vercel deployments do not
|
||||
* apply the `vercel.json` host rewrites (the Nitro Build Output API config
|
||||
* supersedes them), so the subdomain page needs this top-level dispatch file
|
||||
* to be reachable — without it `nessa.freno.me/terms` is a 404 even though
|
||||
* `src/routes/nessa/terms.tsx` exists.
|
||||
*
|
||||
* Only Nessa publishes terms of use today. Every other site — and the main
|
||||
* site — falls back to the 404 handler rather than borrowing another
|
||||
* product's legal document.
|
||||
*/
|
||||
export default function TermsPage() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Switch fallback={<NotFound />}>
|
||||
<Match when={site().id === "nessa"}>
|
||||
<NessaTermsOfUse />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
381
src/server/weekly-metrics.test.ts
Normal file
381
src/server/weekly-metrics.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import {
|
||||
VISITOR_ANALYTICS_COLUMNS,
|
||||
VISITOR_ANALYTICS_DDL,
|
||||
VISITOR_ANALYTICS_INSERT
|
||||
} from "./analytics-schema";
|
||||
import {
|
||||
NOOK_DOWNLOAD_PATH,
|
||||
getWeeklyReport,
|
||||
pickMover,
|
||||
readCampaigns,
|
||||
readFunnel,
|
||||
readTraffic,
|
||||
renderWeeklyMarkdown,
|
||||
windowsFor,
|
||||
type Connections,
|
||||
type FunnelCounts,
|
||||
type MetricsConn
|
||||
} from "./weekly-metrics";
|
||||
|
||||
// Same reasoning as the backfill suite: the queries run real SQL, so an
|
||||
// in-memory SQLite shaped like production is the only honest stand-in. A stub
|
||||
// that returned canned rows would pass with a column or a label the live table
|
||||
// does not have.
|
||||
|
||||
const NOOK_DDL = [
|
||||
`CREATE TABLE trials (
|
||||
fingerprint TEXT PRIMARY KEY,
|
||||
started_at TEXT NOT NULL,
|
||||
email TEXT
|
||||
)`,
|
||||
`CREATE TABLE licenses (
|
||||
id TEXT PRIMARY KEY,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
stripe_session_id TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE activations (
|
||||
id TEXT PRIMARY KEY,
|
||||
license_id TEXT NOT NULL,
|
||||
device_fingerprint TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL,
|
||||
activated_at TEXT NOT NULL,
|
||||
deactivated_at TEXT
|
||||
)`
|
||||
];
|
||||
|
||||
function adapter(db: Database): MetricsConn {
|
||||
return {
|
||||
async execute(query) {
|
||||
const args = (query.args ?? []) as never[];
|
||||
const statement = db.query(query.sql);
|
||||
if (/^\s*(select|pragma|with)\b/i.test(query.sql)) {
|
||||
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
|
||||
}
|
||||
return {
|
||||
rows: [] as unknown[],
|
||||
rowsAffected: statement.run(...args).changes
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let analyticsDb: Database;
|
||||
let nookDb: Database;
|
||||
let conns: Connections;
|
||||
|
||||
const NOW = new Date("2026-10-29T16:00:00Z");
|
||||
|
||||
const row = (
|
||||
overrides: Record<string, unknown>
|
||||
): Record<string, unknown> => ({
|
||||
id: crypto.randomUUID(),
|
||||
user_id: null,
|
||||
path: "/",
|
||||
method: "GET",
|
||||
referrer: null,
|
||||
user_agent: "Mozilla/5.0 (Macintosh)",
|
||||
ip_address: "203.0.113.9",
|
||||
country: "GB",
|
||||
device_type: "desktop",
|
||||
browser: "safari",
|
||||
os: "macos",
|
||||
utm_source: null,
|
||||
utm_medium: null,
|
||||
utm_campaign: null,
|
||||
duration_ms: null,
|
||||
fcp: null,
|
||||
lcp: null,
|
||||
cls: null,
|
||||
fid: null,
|
||||
inp: null,
|
||||
ttfb: null,
|
||||
dom_load: null,
|
||||
load_complete: null,
|
||||
...overrides
|
||||
});
|
||||
|
||||
function insertAnalytics(overrides: Record<string, unknown>): void {
|
||||
const values = row(overrides);
|
||||
analyticsDb
|
||||
.prepare(VISITOR_ANALYTICS_INSERT)
|
||||
.run(...(VISITOR_ANALYTICS_COLUMNS.map((column) => values[column]) as never[]));
|
||||
|
||||
// `created_at` is not in VISITOR_ANALYTICS_COLUMNS (it defaults to now), so
|
||||
// the window each row belongs to has to be set after the insert.
|
||||
if (values.created_at) {
|
||||
analyticsDb
|
||||
.prepare(`UPDATE VisitorAnalytics SET created_at = ? WHERE id = ?`)
|
||||
.run(values.created_at as string, values.id as string);
|
||||
}
|
||||
}
|
||||
|
||||
// Inside the current window for NOW - 7 days: 2026-10-22 16:00 → 2026-10-29 16:00.
|
||||
const THIS_WEEK = "2026-10-27 10:00:00";
|
||||
// Inside the previous window: 2026-10-15 16:00 → 2026-10-22 16:00.
|
||||
const LAST_WEEK = "2026-10-18 10:00:00";
|
||||
|
||||
beforeEach(() => {
|
||||
analyticsDb = new Database(":memory:");
|
||||
analyticsDb.run(VISITOR_ANALYTICS_DDL);
|
||||
nookDb = new Database(":memory:");
|
||||
for (const ddl of NOOK_DDL) nookDb.run(ddl);
|
||||
conns = { analytics: adapter(analyticsDb), nook: adapter(nookDb) };
|
||||
});
|
||||
|
||||
describe("windowsFor", () => {
|
||||
it("puts the previous window directly before the current one", () => {
|
||||
const windows = windowsFor(NOW, 7);
|
||||
expect(windows.current).toEqual({
|
||||
start: "2026-10-22 16:00:00",
|
||||
end: "2026-10-29 16:00:00"
|
||||
});
|
||||
expect(windows.previous).toEqual({
|
||||
start: "2026-10-15 16:00:00",
|
||||
end: "2026-10-22 16:00:00"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFunnel", () => {
|
||||
it("counts each stage inside its window and nothing outside it", async () => {
|
||||
// Page views count; API calls do not.
|
||||
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/downloads", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/api/trpc/anything", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/", created_at: LAST_WEEK });
|
||||
|
||||
// The merged download label, click and served alike.
|
||||
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: NOOK_DOWNLOAD_PATH, created_at: THIS_WEEK });
|
||||
|
||||
// Checkout page doors, including the legacy /nook/checkout path.
|
||||
insertAnalytics({ path: "/checkout", created_at: THIS_WEEK });
|
||||
insertAnalytics({ path: "/nook/checkout", created_at: THIS_WEEK });
|
||||
|
||||
// The licence DB stores ISO-8601 with milliseconds.
|
||||
nookDb
|
||||
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
|
||||
.run("fp-1", "2026-10-28T20:28:46.806Z");
|
||||
nookDb
|
||||
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
|
||||
.run("fp-old", "2026-08-26T19:26:12.791Z");
|
||||
nookDb
|
||||
.prepare(
|
||||
`INSERT INTO licenses (id, key, email, stripe_session_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run("lic-1", "NOOK-1", "buyer@example.com", "cs_1", "2026-10-25T12:00:00.000Z");
|
||||
nookDb
|
||||
.prepare(
|
||||
`INSERT INTO activations (id, license_id, device_fingerprint, device_name, activated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run("act-1", "lic-1", "device-1", "Hermes", "2026-10-25T12:05:00.000Z");
|
||||
|
||||
const window = windowsFor(NOW, 7).current;
|
||||
const funnel = await readFunnel(conns, window);
|
||||
|
||||
// "/checkout" and "/nook/checkout" are page views too, so they land in
|
||||
// both the visit count and the checkout count.
|
||||
expect(funnel).toEqual({
|
||||
visit: 4,
|
||||
download: 2,
|
||||
trial: 1,
|
||||
checkout: 2,
|
||||
purchase: 1,
|
||||
activation: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("reports zeros against an empty database", async () => {
|
||||
const window = windowsFor(NOW, 7).current;
|
||||
expect(await readFunnel(conns, window)).toEqual({
|
||||
visit: 0,
|
||||
download: 0,
|
||||
trial: 0,
|
||||
checkout: 0,
|
||||
purchase: 0,
|
||||
activation: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readTraffic", () => {
|
||||
it("separates loopback and automation from browser traffic", async () => {
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "curl/8.7.1",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
ip_address: "::1",
|
||||
user_agent: "The Nook/0.13.4 Sparkle/2.9.6",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
ip_address: "203.0.113.9",
|
||||
user_agent: "Mozilla/5.0 (Macintosh) Safari/605.1.15",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({ path: "/", ip_address: "203.0.113.9", created_at: THIS_WEEK });
|
||||
|
||||
const traffic = await readTraffic(conns, windowsFor(NOW, 7).current);
|
||||
|
||||
expect(traffic).toEqual({
|
||||
analyticsRows: 4,
|
||||
loopbackRows: 2,
|
||||
allTimeRows: 4,
|
||||
allTimeAddressedRows: 4,
|
||||
allTimeLoopbackRows: 2,
|
||||
downloadRows: 3,
|
||||
downloadLoopbackRows: 2,
|
||||
downloadNonBrowserRows: 2
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCampaigns", () => {
|
||||
it("groups tagged rows, excludes untagged, and orders by volume", async () => {
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
utm_source: "hn",
|
||||
utm_medium: "post",
|
||||
utm_campaign: "launch",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
utm_source: "hn",
|
||||
utm_medium: "post",
|
||||
utm_campaign: "launch",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
utm_source: "r_macapps",
|
||||
utm_campaign: "week2",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||
|
||||
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
|
||||
|
||||
expect(campaigns).toEqual([
|
||||
{ source: "hn", medium: "post", campaign: "launch", count: 2 },
|
||||
{ source: "r_macapps", medium: null, campaign: "week2", count: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps the list at ten rows", async () => {
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
insertAnalytics({
|
||||
path: "/",
|
||||
utm_source: `source-${i}`,
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
}
|
||||
|
||||
const campaigns = await readCampaigns(conns.analytics, windowsFor(NOW, 7).current);
|
||||
expect(campaigns.length).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickMover", () => {
|
||||
const base: FunnelCounts = {
|
||||
visit: 0,
|
||||
download: 0,
|
||||
trial: 0,
|
||||
checkout: 0,
|
||||
purchase: 0,
|
||||
activation: 0
|
||||
};
|
||||
|
||||
it("picks the stage with the largest absolute change", () => {
|
||||
const mover = pickMover(
|
||||
{ ...base, visit: 61, download: 156, trial: 2 },
|
||||
{ ...base, visit: 56 }
|
||||
);
|
||||
expect(mover).toEqual({
|
||||
key: "download",
|
||||
label: "download click → DMG served (one label)",
|
||||
now: 156,
|
||||
before: 0,
|
||||
delta: 156
|
||||
});
|
||||
});
|
||||
|
||||
it("breaks ties toward the earlier stage", () => {
|
||||
const mover = pickMover(
|
||||
{ ...base, visit: 5, download: 5 },
|
||||
{ ...base, visit: 0, download: 0 }
|
||||
);
|
||||
expect(mover?.key).toBe("visit");
|
||||
});
|
||||
|
||||
it("returns null when nothing moved", () => {
|
||||
expect(pickMover({ ...base, visit: 3 }, { ...base, visit: 3 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWeeklyReport / renderWeeklyMarkdown", () => {
|
||||
it("reports a week in the agreed stage order with the computed caveats", async () => {
|
||||
insertAnalytics({ path: "/", created_at: THIS_WEEK });
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "curl/8.7.1",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
insertAnalytics({
|
||||
path: NOOK_DOWNLOAD_PATH,
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "curl/8.7.1",
|
||||
created_at: THIS_WEEK
|
||||
});
|
||||
nookDb
|
||||
.prepare(`INSERT INTO trials (fingerprint, started_at) VALUES (?, ?)`)
|
||||
.run("fp-1", "2026-10-28T20:28:46.806Z");
|
||||
|
||||
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
expect(report.mover?.key).toBe("download");
|
||||
expect(markdown).toContain(
|
||||
"Window: **2026-10-22 16:00:00 → 2026-10-29 16:00:00 UTC** (7 days)"
|
||||
);
|
||||
expect(markdown).toContain("| visit (page views) | 1 | 0 | +1 |");
|
||||
expect(markdown.indexOf("visit (page views)")).toBeLessThan(
|
||||
markdown.indexOf("download click → DMG served")
|
||||
);
|
||||
expect(markdown.indexOf("download click → DMG served")).toBeLessThan(
|
||||
markdown.indexOf("trial started")
|
||||
);
|
||||
expect(markdown).toContain("**download click → DMG served (one label): 0 → 2 (+2).**");
|
||||
expect(markdown).toContain("2 of 3 rows in the window came from a loopback address");
|
||||
expect(markdown).toContain("Checkout start is not recorded");
|
||||
expect(markdown).toContain("There is no host dimension");
|
||||
});
|
||||
|
||||
it("says nothing moved rather than inventing a winner", async () => {
|
||||
const report = await getWeeklyReport(conns, { now: NOW, days: 7 });
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
expect(report.mover).toBeNull();
|
||||
expect(markdown).toContain("_Nothing moved: every stage held its previous-week value._");
|
||||
expect(markdown).toContain("_No tagged traffic in the window._");
|
||||
});
|
||||
|
||||
it("rejects a window that is not a positive number of days", async () => {
|
||||
await expect(getWeeklyReport(conns, { now: NOW, days: 0 })).rejects.toThrow(
|
||||
"days must be a positive number"
|
||||
);
|
||||
});
|
||||
});
|
||||
591
src/server/weekly-metrics.ts
Normal file
591
src/server/weekly-metrics.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* The weekly funnel reading: `marketing/metrics/weekly.md`'s data half.
|
||||
*
|
||||
* The funnel the marketing cluster agreed on is
|
||||
*
|
||||
* visit → download click → DMG served → trial started → checkout started
|
||||
* → purchase → activation
|
||||
*
|
||||
* and this module reads it from the two live databases, plus the campaign
|
||||
* dimension the /analytics page already exposes through
|
||||
* `getAnalyticsSummary.topCampaigns`.
|
||||
*
|
||||
* Two properties matter more than the numbers:
|
||||
*
|
||||
* - It takes both connections as arguments. `~/server/analytics` imports the
|
||||
* full server env (Stripe keys, Apple secret, licence key …); the weekly
|
||||
* job only needs the two databases, and must be runnable from a checkout
|
||||
* whose `.env` carries nothing else. The SQL here is the same shape as the
|
||||
* analytics summaries, so the weekly read and the dashboard cannot drift
|
||||
* into different label sets.
|
||||
* - Every stage carries what it actually measured. Two of the seven agreed
|
||||
* stages share one label in production today and a third is only a page-view
|
||||
* proxy; the report states that next to the number instead of inventing a
|
||||
* figure (see `renderWeeklyMarkdown`).
|
||||
*
|
||||
* No `~/env`, no `~/server/database`: the caller owns the connections, the
|
||||
* tests own an in-memory SQLite, and the script owns the libsql clients.
|
||||
*/
|
||||
|
||||
import { DOWNLOAD_PATH_PREFIX } from "~/lib/download-asset";
|
||||
|
||||
/** The one asset key the Nook lands under — `downloadPath("thenook")`. */
|
||||
export const NOOK_DOWNLOAD_PATH = `${DOWNLOAD_PATH_PREFIX}thenook`;
|
||||
|
||||
/** An `execute`-shaped connection: the libsql client and the test adapter both fit. */
|
||||
export interface MetricsConn {
|
||||
execute(query: {
|
||||
sql: string;
|
||||
args?: any[];
|
||||
}): Promise<{ rows: unknown[]; rowsAffected?: number }>;
|
||||
}
|
||||
|
||||
export interface Connections {
|
||||
/** freno.me Turso DB — VisitorAnalytics. */
|
||||
analytics: MetricsConn;
|
||||
/** the-nook Turso DB — trials, licenses, activations. */
|
||||
nook: MetricsConn;
|
||||
}
|
||||
|
||||
export type StageKey =
|
||||
| "visit"
|
||||
| "download"
|
||||
| "trial"
|
||||
| "checkout"
|
||||
| "purchase"
|
||||
| "activation";
|
||||
|
||||
export interface StageDef {
|
||||
key: StageKey;
|
||||
label: string;
|
||||
/** True when the number is a proxy or a merged label, not a clean stage. */
|
||||
approximate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The seven agreed stages. `download` deliberately carries two of them: the
|
||||
* door and the file route both write `/api/downloads/thenook`, so a click and
|
||||
* the DMG it fetched are one label in the data (proved in the report's caveats).
|
||||
*/
|
||||
export const STAGES: StageDef[] = [
|
||||
{ key: "visit", label: "visit (page views)", approximate: false },
|
||||
{
|
||||
key: "download",
|
||||
label: "download click → DMG served (one label)",
|
||||
approximate: true
|
||||
},
|
||||
{ key: "trial", label: "trial started", approximate: false },
|
||||
{
|
||||
key: "checkout",
|
||||
label: "checkout page visit",
|
||||
approximate: true
|
||||
},
|
||||
{ key: "purchase", label: "purchase (licence issued)", approximate: false },
|
||||
{ key: "activation", label: "activation", approximate: false }
|
||||
];
|
||||
|
||||
export interface FunnelCounts {
|
||||
visit: number;
|
||||
download: number;
|
||||
trial: number;
|
||||
checkout: number;
|
||||
purchase: number;
|
||||
activation: number;
|
||||
}
|
||||
|
||||
export interface CampaignRow {
|
||||
source: string | null;
|
||||
medium: string | null;
|
||||
campaign: string | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Traffic-shape facts the report has to state next to the funnel. */
|
||||
export interface WindowTraffic {
|
||||
analyticsRows: number;
|
||||
loopbackRows: number;
|
||||
/** All-time row counts, so "every row ever" is a checked claim, not a guess. */
|
||||
allTimeRows: number;
|
||||
allTimeAddressedRows: number;
|
||||
allTimeLoopbackRows: number;
|
||||
downloadRows: number;
|
||||
downloadLoopbackRows: number;
|
||||
downloadNonBrowserRows: number;
|
||||
}
|
||||
|
||||
export interface Mover {
|
||||
key: StageKey;
|
||||
label: string;
|
||||
now: number;
|
||||
before: number;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface WeeklyReport {
|
||||
window: { start: string; end: string; days: number };
|
||||
previous: { start: string; end: string };
|
||||
counts: FunnelCounts;
|
||||
previousCounts: FunnelCounts;
|
||||
campaigns: CampaignRow[];
|
||||
taggedRows: number;
|
||||
traffic: WindowTraffic;
|
||||
mover: Mover | null;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/** Loopback addresses — every row the live table has ever held. */
|
||||
const LOOPBACK = ["127.0.0.1", "::1", "::ffff:127.0.0.1"];
|
||||
|
||||
/** SQLite's `datetime()` string, UTC: `YYYY-MM-DD HH:MM:SS`. */
|
||||
export function sqlTime(date: Date): string {
|
||||
return date.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export interface Windows {
|
||||
current: { start: string; end: string };
|
||||
previous: { start: string; end: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The current window is `[now - days, now)` and the previous one sits directly
|
||||
* before it, so a weekly read always compares like with like. Boundaries are
|
||||
* passed as SQL parameters rather than `datetime('now', …)` so the file a run
|
||||
* writes is reproducible and the two queries cannot straddle a second.
|
||||
*/
|
||||
export function windowsFor(now: Date, days: number): Windows {
|
||||
const span = days * 24 * 60 * 60 * 1000;
|
||||
const end = now;
|
||||
const start = new Date(end.getTime() - span);
|
||||
const previousEnd = start;
|
||||
const previousStart = new Date(previousEnd.getTime() - span);
|
||||
|
||||
return {
|
||||
current: { start: sqlTime(start), end: sqlTime(end) },
|
||||
previous: { start: sqlTime(previousStart), end: sqlTime(previousEnd) }
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "bigint") return Number(value);
|
||||
if (typeof value === "string") return Number(value) || 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function scalar(
|
||||
conn: MetricsConn,
|
||||
sql: string,
|
||||
args: any[]
|
||||
): Promise<number> {
|
||||
const result = await conn.execute({ sql, args });
|
||||
const row = result.rows[0] as Record<string, unknown> | undefined;
|
||||
if (!row) return 0;
|
||||
return toNumber(Object.values(row)[0]);
|
||||
}
|
||||
|
||||
async function countInWindow(
|
||||
conn: MetricsConn,
|
||||
sql: string,
|
||||
start: string,
|
||||
end: string
|
||||
): Promise<number> {
|
||||
return scalar(conn, sql, [start, end]);
|
||||
}
|
||||
|
||||
/** One window's funnel. Two databases, one call. */
|
||||
export async function readFunnel(
|
||||
conns: Connections,
|
||||
window: { start: string; end: string }
|
||||
): Promise<FunnelCounts> {
|
||||
const { analytics, nook } = conns;
|
||||
const { start, end } = window;
|
||||
|
||||
const visit = await countInWindow(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND path NOT LIKE '/api/%'`,
|
||||
start,
|
||||
end
|
||||
);
|
||||
|
||||
const download = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND path = ?`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
const checkout = await countInWindow(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND (path LIKE '/checkout%' OR path LIKE '/nook/checkout%')`,
|
||||
start,
|
||||
end
|
||||
);
|
||||
|
||||
// The licence DB stores ISO-8601 (`2026-10-28T20:28:46.806Z`); the analytics
|
||||
// DB stores `YYYY-MM-DD HH:MM:SS`. `datetime()` normalises both sides so a
|
||||
// boundary seconds apart cannot include or drop a row on string comparison.
|
||||
const trial = await scalar(
|
||||
nook,
|
||||
`SELECT COUNT(*) AS n FROM trials
|
||||
WHERE datetime(started_at) >= datetime(?) AND datetime(started_at) < datetime(?)`,
|
||||
[start, end]
|
||||
);
|
||||
|
||||
const purchase = await scalar(
|
||||
nook,
|
||||
`SELECT COUNT(*) AS n FROM licenses
|
||||
WHERE datetime(created_at) >= datetime(?) AND datetime(created_at) < datetime(?)`,
|
||||
[start, end]
|
||||
);
|
||||
|
||||
const activation = await scalar(
|
||||
nook,
|
||||
`SELECT COUNT(*) AS n FROM activations
|
||||
WHERE datetime(activated_at) >= datetime(?) AND datetime(activated_at) < datetime(?)`,
|
||||
[start, end]
|
||||
);
|
||||
|
||||
return {
|
||||
visit,
|
||||
download,
|
||||
trial,
|
||||
checkout,
|
||||
purchase,
|
||||
activation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traffic shape for one window: how much of it came from this host, and how
|
||||
* much of the download label was automation. Without these two facts the
|
||||
* numbers read as demand, which they are not.
|
||||
*/
|
||||
export async function readTraffic(
|
||||
conns: Connections,
|
||||
window: { start: string; end: string }
|
||||
): Promise<WindowTraffic> {
|
||||
const { analytics } = conns;
|
||||
const { start, end } = window;
|
||||
const placeholders = LOOPBACK.map(() => "?").join(", ");
|
||||
|
||||
const allTimeRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics`,
|
||||
[]
|
||||
);
|
||||
|
||||
const allTimeAddressedRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics WHERE ip_address IS NOT NULL`,
|
||||
[]
|
||||
);
|
||||
|
||||
const allTimeLoopbackRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE ip_address IN (${placeholders})`,
|
||||
[...LOOPBACK]
|
||||
);
|
||||
|
||||
const analyticsRows = await countInWindow(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?`,
|
||||
start,
|
||||
end
|
||||
);
|
||||
|
||||
const loopbackRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND ip_address IN (${placeholders})`,
|
||||
[start, end, ...LOOPBACK]
|
||||
);
|
||||
|
||||
const downloadRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ? AND path = ?`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
const downloadLoopbackRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ? AND path = ?
|
||||
AND ip_address IN (${placeholders})`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH, ...LOOPBACK]
|
||||
);
|
||||
|
||||
// Anything whose UA is not a browser engine: curl, Sparkle's updater,
|
||||
// link-preview crawlers. A real install click arrives as `Mozilla/5.0 …`.
|
||||
const downloadNonBrowserRows = await scalar(
|
||||
analytics,
|
||||
`SELECT COUNT(*) AS n FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ? AND path = ?
|
||||
AND (user_agent IS NULL OR user_agent NOT LIKE 'Mozilla/%')`,
|
||||
[start, end, NOOK_DOWNLOAD_PATH]
|
||||
);
|
||||
|
||||
return {
|
||||
analyticsRows,
|
||||
loopbackRows,
|
||||
allTimeRows,
|
||||
allTimeAddressedRows,
|
||||
allTimeLoopbackRows,
|
||||
downloadRows,
|
||||
downloadLoopbackRows,
|
||||
downloadNonBrowserRows
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The campaign dimension, grouped exactly as `getAnalyticsSummary.topCampaigns`
|
||||
* groups it: top 10 by volume, untagged rows excluded (the untagged bucket is
|
||||
* a different question and would otherwise swallow the list).
|
||||
*/
|
||||
export async function readCampaigns(
|
||||
analytics: MetricsConn,
|
||||
window: { start: string; end: string }
|
||||
): Promise<CampaignRow[]> {
|
||||
const result = await analytics.execute({
|
||||
sql: `SELECT utm_source, utm_medium, utm_campaign, COUNT(*) AS count
|
||||
FROM VisitorAnalytics
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND (utm_source IS NOT NULL OR utm_medium IS NOT NULL OR utm_campaign IS NOT NULL)
|
||||
GROUP BY utm_source, utm_medium, utm_campaign
|
||||
ORDER BY count DESC
|
||||
LIMIT 10`,
|
||||
args: [window.start, window.end]
|
||||
});
|
||||
|
||||
return result.rows.map((raw) => {
|
||||
const row = raw as Record<string, unknown>;
|
||||
return {
|
||||
source: (row.utm_source as string | null) ?? null,
|
||||
medium: (row.utm_medium as string | null) ?? null,
|
||||
campaign: (row.utm_campaign as string | null) ?? null,
|
||||
count: toNumber(row.count)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "The one thing that moved": the stage with the largest absolute change
|
||||
* against the previous window. Ties go to the earlier stage, so a week when
|
||||
* nothing moved returns null rather than a made-up winner.
|
||||
*/
|
||||
export function pickMover(
|
||||
current: FunnelCounts,
|
||||
previous: FunnelCounts
|
||||
): Mover | null {
|
||||
let best: Mover | null = null;
|
||||
|
||||
for (const stage of STAGES) {
|
||||
const delta = current[stage.key] - previous[stage.key];
|
||||
if (delta === 0) continue;
|
||||
if (!best || Math.abs(delta) > Math.abs(best.delta)) {
|
||||
best = {
|
||||
key: stage.key,
|
||||
label: stage.label,
|
||||
now: current[stage.key],
|
||||
before: previous[stage.key],
|
||||
delta
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface ReportOptions {
|
||||
/** End of the current window; defaults to the wall clock. */
|
||||
now?: Date;
|
||||
/** Window length in days; defaults to 7. */
|
||||
days?: number;
|
||||
}
|
||||
|
||||
/** Read everything `.md` needs, in one call. */
|
||||
export async function getWeeklyReport(
|
||||
conns: Connections,
|
||||
options: ReportOptions = {}
|
||||
): Promise<WeeklyReport> {
|
||||
const days = options.days ?? 7;
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
throw new Error(`days must be a positive number, got ${days}`);
|
||||
}
|
||||
|
||||
const now = options.now ?? new Date();
|
||||
const windows = windowsFor(now, days);
|
||||
|
||||
const [counts, previousCounts, traffic, campaigns] = await Promise.all([
|
||||
readFunnel(conns, windows.current),
|
||||
readFunnel(conns, windows.previous),
|
||||
readTraffic(conns, windows.current),
|
||||
readCampaigns(conns.analytics, windows.current)
|
||||
]);
|
||||
|
||||
return {
|
||||
window: { ...windows.current, days },
|
||||
previous: windows.previous,
|
||||
counts,
|
||||
previousCounts,
|
||||
campaigns,
|
||||
taggedRows: campaigns.reduce((sum, row) => sum + row.count, 0),
|
||||
traffic,
|
||||
mover: pickMover(counts, previousCounts),
|
||||
generatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z")
|
||||
};
|
||||
}
|
||||
|
||||
function cell(value: string | null): string {
|
||||
return value === null || value === "" ? "—" : value;
|
||||
}
|
||||
|
||||
function signed(value: number): string {
|
||||
if (value === 0) return "0";
|
||||
return value > 0 ? `+${value}` : `−${Math.abs(value)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the weekly file. Interpretation lives in the CMO's read; this renderer
|
||||
* only states what the data is, what moved, and what the numbers cannot say.
|
||||
* The Gaps section is not boilerplate: each bullet is computed from the window
|
||||
* it describes, so it empties itself if the underlying hole is ever fixed.
|
||||
*/
|
||||
export function renderWeeklyMarkdown(report: WeeklyReport): string {
|
||||
const { counts, previousCounts, traffic } = report;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("# Weekly funnel snapshot");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Window: **${report.window.start} → ${report.window.end} UTC** (${report.window.days} days). ` +
|
||||
`Previous: ${report.previous.start} → ${report.previous.end} UTC.`
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated " +
|
||||
"weekly, never hand-edited. The analysis is the CMO's."
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Funnel");
|
||||
lines.push("");
|
||||
lines.push("| Stage | This week | Last week | Change |");
|
||||
lines.push("|---|---:|---:|---:|");
|
||||
for (const stage of STAGES) {
|
||||
const now = counts[stage.key];
|
||||
const before = previousCounts[stage.key];
|
||||
const label = stage.approximate ? `${stage.label} †` : stage.label;
|
||||
lines.push(`| ${label} | ${now} | ${before} | ${signed(now - before)} |`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Campaigns");
|
||||
lines.push("");
|
||||
if (report.campaigns.length === 0) {
|
||||
lines.push("_No tagged traffic in the window._");
|
||||
} else {
|
||||
lines.push(
|
||||
"Top 10 by volume, untagged excluded — the same grouping " +
|
||||
"`getAnalyticsSummary.topCampaigns` returns."
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("| Source | Medium | Campaign | Rows |");
|
||||
lines.push("|---|---|---|---:|");
|
||||
for (const row of report.campaigns) {
|
||||
lines.push(
|
||||
`| ${cell(row.source)} | ${cell(row.medium)} | ${cell(row.campaign)} | ${row.count} |`
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`${report.taggedRows} of ${traffic.analyticsRows} rows in the window carry ` +
|
||||
`campaign tags; the rest are untagged and therefore unattributed.`
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## The one thing that moved");
|
||||
lines.push("");
|
||||
if (!report.mover) {
|
||||
lines.push("_Nothing moved: every stage held its previous-week value._");
|
||||
} else {
|
||||
const mover = report.mover;
|
||||
lines.push(
|
||||
`**${mover.label}: ${mover.before} → ${mover.now} (${signed(mover.delta)}).**`
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Before acting on these numbers");
|
||||
lines.push("");
|
||||
|
||||
if (traffic.analyticsRows > 0 && traffic.loopbackRows > 0) {
|
||||
lines.push(
|
||||
`- **${traffic.loopbackRows} of ${traffic.analyticsRows} rows in the window came from a loopback address** ` +
|
||||
`(127.0.0.1 / ::1)` +
|
||||
(traffic.allTimeAddressedRows > 0 &&
|
||||
traffic.allTimeAddressedRows === traffic.allTimeLoopbackRows
|
||||
? `; every one of the ${traffic.allTimeAddressedRows} addressed rows the table ` +
|
||||
`has ever held is loopback (${traffic.allTimeRows - traffic.allTimeAddressedRows} carry no address)`
|
||||
: `; across the table's history, ${traffic.allTimeLoopbackRows} of ` +
|
||||
`${traffic.allTimeAddressedRows} addressed rows are loopback`) +
|
||||
`. Either this host is the only audience, or the app sits behind a local proxy ` +
|
||||
`and \`getRequestIP\` records the proxy, not the visitor. Either way the address ` +
|
||||
`identifies nobody — "unique visitors" collapses to one host and per-channel ` +
|
||||
`reach cannot be read from it.`
|
||||
);
|
||||
}
|
||||
|
||||
if (traffic.downloadRows > 0) {
|
||||
lines.push(
|
||||
`- **The download stage is one label, not two.** The landing-door route and the file ` +
|
||||
`route that serves the DMG both write \`${NOOK_DOWNLOAD_PATH}\`, so ` +
|
||||
`"download click → DMG served" cannot be split: one served click writes two rows, ` +
|
||||
`a click that fails to resolve writes one, and a Sparkle update writes one. ` +
|
||||
`Of this window's ${traffic.downloadRows} download rows, ` +
|
||||
`${traffic.downloadLoopbackRows} are loopback and ` +
|
||||
`${traffic.downloadNonBrowserRows} are not browser user agents (curl, Sparkle, ` +
|
||||
`link-preview crawlers).`
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`- **Checkout start is not recorded.** \`POST /api/the-nook/checkout\` — the call that ` +
|
||||
`actually creates the Stripe session — writes nothing to analytics; the only trace is ` +
|
||||
`the /checkout page view reported above, which is a page, not a started checkout.`
|
||||
);
|
||||
|
||||
lines.push(
|
||||
`- **There is no host dimension.** \`VisitorAnalytics\` records a path, not the site, so ` +
|
||||
`the Nook's landing page (\`/\` on nook.freno.me) is the same row as freno.me's root. ` +
|
||||
`"visit" above is site-wide page views, not Nook visits.`
|
||||
);
|
||||
|
||||
lines.push(
|
||||
`- **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.`
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push(
|
||||
"† Proxy or merged label — see *Before acting on these numbers* above."
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## Sources");
|
||||
lines.push("");
|
||||
lines.push("- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.");
|
||||
lines.push("- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.");
|
||||
lines.push(`- Generated ${report.generatedAt}.`);
|
||||
lines.push("");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user