1 Commits

Author SHA1 Message Date
a336a6c690 site: restore the remote SSH copy and say eighteen agents (FRE-997)
The FRE-323 correction took the remote story off the site against claims the
copy never made: hosts do come from ~/.ssh/config and Tailscale
(RemoteHostDiscovery), the one-time ssh-copy-id is a prerequisite the row
hands the user as a command (RemoteHostStatus.fixCommand), the per-host
credential is minted and copied over (RemoteMirror), and a remote host
mirrors this Mac's integrations (RemoteMirrorReconcile). The removals also
dropped the agent count instead of correcting it: the page said thirteen,
AgentSpec.all holds eighteen.

- Revert c1c498e: the Remote collage tile and its mock, the "Any SSH box, one
  click away" breakdown with its RemoteHostDemo, and the "One-click SSH setup"
  chip are back.
- Revert c718370: /agents says remote sessions report into the same grid.
- The approvals bullet no longer claims "every hooked agent"; it names the
  three agents that cannot mirror. Muse, Cline and Amp are .unsupported for
  remote in AgentSpec.remote, so Cline and Amp rows now disclose it too.
- Count is 18 on the landing bullet, the marquee chip and all of /agents
  (title, description, ogTitle, H1 paragraph).
- The five missing rows are on /agents: Kimi, GitHub Copilot CLI, Crush,
  Cursor and Amp, events and gating taken from hook/setup.go,
  hook/normalize.go, hook/normalize_hosts.go, Sources/NookCore/Agents/
  HookConfigInstaller.swift and plugins/amp/index.ts. All five are Observe:
  kimi/copilot PreToolUse+PermissionRequest fire before the host's own check
  (awaitPolicyTable has no entry; kimi's PermissionRequest is
  notification-only), Crush documents one event, a silent Cursor permission
  hook blocks the action, Amp's plugin API has no permission-wait event.
- agents.test.ts asserts equality with the 18-name registry again, and the
  count; remote-claims.test.ts keeps only the two guards worth keeping (no
  universal remote claim, no stale thirteen).
- positioning.md: the held remote row becomes a publishable row with its
  backing, the count row becomes publishable at 18, and
  marketing/drafts/remote-ssh/ is deleted.

bun test src/routes/nook: 17 pass, 0 fail. Against d44133f with these two
test files the same suites give 10 fail (7 pass), so the guards prove the fix.
bun run build: ✔ build done (router client, SSR, server-fns, vercel preset).
bun test src/routes: 124 pass / 20 fail / 2 errors; the same 20 fail and 2
errors on d44133f, so they are pre-existing environment failures.
2026-11-03 10:32:51 +00:00
26 changed files with 944 additions and 1792 deletions

View File

@@ -1,64 +0,0 @@
# Remote SSH copy — historical unqualified wording
[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.
## What was removed
### 1. Landing collage tile
Source: `src/components/nook/FeatureCollage.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Agents on other machines
body: Run agents on Linux boxes and SSH servers — one click installs the
nook-hook, and their sessions join the same island.
```
The mock attached to it showed two session rows whose agent chips named
`build-box` and `gpu-rig`.
### 2. Landing feature breakdown section
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
kicker: Remote
title: Any SSH box, one click away
body: Pick a host from your ssh config or your tailnet and click once — the
nook-hook installs itself and its agents appear beside your local ones,
indistinguishable.
bullets:
- One click: connect, push the hook, configure agents, check it can reach this Mac
- Hosts mirror this Mac's installed integrations automatically
- Remote sessions are ordinary rows; the agent chip names the machine
```
The section's demo animated a `gpu-rig` host moving from "Not set up" through
`connecting` → `pushing the hook binary` → `configuring agents` → `checking the
host can reach this Mac` → `Live`, then showed a remote session row.
### 3. Approvals bullet
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
```
- Works across every hooked agent, local or remote
```
### 4. Feature chip
Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
```
One-click SSH setup
```
## Current restoration
[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.
[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.

View File

@@ -1,13 +0,0 @@
# Remote setup marketing decision
[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.
## Why it was removed
[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.
## Current evidence and scope
[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.
[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.

View File

@@ -4,12 +4,13 @@ Every drafted asset draws its voice, claims, and constraints from this file. If
artifact invents its own framing, fix the artifact or fix this file — never both artifact invents its own framing, fix the artifact or fix this file — never both
silently diverging. silently diverging.
**Reconciled 2026-10-29** against the CTO's verdicts ([FRE-315], [FRE-322], **Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 correction,
[FRE-354]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354], [FRE-997]), the Chief
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is of Staff's price decision (2026-10-29 04:01), the Plan B launch route ([FRE-346] →
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous [FRE-203] `plan-b`), and `The-Nook` master (app floor is **14.2**, not 14.0 —
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that `freno-dev` `881190d`, site merge `134f41e`). The previous version of this file
no longer exists; the verdicts below are the source of truth it was pointing at. named an upstream `the-nook/docs/marketing-strategy.md` that no longer exists; the
verdicts below are the source of truth it was pointing at.
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315 [FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322 [FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
@@ -17,6 +18,7 @@ no longer exists; the verdicts below are the source of truth it was pointing at.
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320 [FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346 [FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203 [FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
## The sentence ## The sentence
@@ -113,13 +115,16 @@ 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 all, and never claim approvals-from-phone as a shipped capability without its own
CTO verdict — no "(coming soon)" parentheticals either way. CTO verdict — no "(coming soon)" parentheticals either way.
[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring remote-agent marketing. Remote SSH is **publishable**, and the site sells it again ([FRE-997]). The
One-click setup works for **Tailscale and authorized known hosts**. Describe copy that was pulled on 2026-10-29 had been checked against claims it never made:
selecting a discovered host and clicking Set up; show host-labelled sessions in hosts come from `~/.ssh/config` and Tailscale (`RemoteHostDiscovery`), the
the island. `RemoteHostsController.setUp` runs installation and a return-path one-time `ssh-copy-id` is a prerequisite the row hands the user as a command
probe. A host that still needs SSH authorization is a separate case, not a reason (`RemoteHostStatus.fixCommand`), the per-host credential is minted and copied
to deny the working one-click flow. `marketing/drafts/remote-ssh/` retains the old over (`RemoteMirror`), and a remote host mirrors this Mac's integrations
unqualified wording and records this revised decision. (`RemoteMirrorReconcile`). Two things in the old copy were wrong and were fixed
rather than restored: the "every hooked agent" overstatement, and a count that
said thirteen against a registry of eighteen. Muse, Cline and Amp are
`.unsupported` for remote in `AgentSpec.remote`, and the copy names them.
### 5. Humans hold every account ### 5. Humans hold every account
@@ -127,7 +132,7 @@ No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
without disclosure. `approved/` **is the only thing a human ever publishes from** — without disclosure. `approved/` **is the only thing a human ever publishes from** —
nothing posts itself. nothing posts itself.
## Claims: status at 2026-10-29 ## Claims: status at 2026-11-03
Each row names the evidence, not a feeling. Re-read the verdict before using a row. Each row names the evidence, not a feeling. Re-read the verdict before using a row.
@@ -138,16 +143,17 @@ Each row names the evidence, not a feeling. Re-read the verdict before using a r
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". | | The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. | | Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. | | 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. | | Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable, and `every` / `all` are still banned without naming the three local-only agents. |
| 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. | | 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 | **Publishable, with the three local-only agents named** | [FRE-997]: `RemoteHostDiscovery` (ssh config + tailnet), `RemoteMirror` (hook push, credential, mirroring), `RemoteMirrorReconcile`, `RemoteHostStatus.fixCommand` (the one-time `ssh-copy-id` the row shows). `AgentSpec.remote` is `.unsupported` for Muse, Cline and Amp — the landing bullet and /agents paragraph name them; never say "every hooked agent". |
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). | | "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. | | Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
## Voice ## Voice
- Short, concrete, no hype adjectives. Numbers over adjectives — but no coverage - Short, concrete, no hype adjectives. Numbers over adjectives — the only count
counts (see the excluded row above). that ships is eighteen, and it is the registry's own size (see the coverage row
above).
- The notch is the product's home; agents are the product's point. - The notch is the product's home; agents are the product's point.
- Never apologize for being a utility. The utility is the point. - Never apologize for being a utility. The utility is the point.
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary", - Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",

View File

@@ -761,14 +761,6 @@ a.hover-underline-animation:hover::after {
animation: nook-pulse 0.8s steps(2, jump-none) infinite; 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 */ /* Live signal inside a mixer fader */
.nook-meter { .nook-meter {
transform-origin: left center; transform-origin: left center;
@@ -848,7 +840,6 @@ a.hover-underline-animation:hover::after {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.nook-power-flow,
.nook-meter, .nook-meter,
.nook-wave, .nook-wave,
.nook-progress, .nook-progress,

View File

@@ -1,5 +1,6 @@
import { import {
For, For,
Show,
createEffect, createEffect,
createSignal, createSignal,
onCleanup, onCleanup,
@@ -10,6 +11,7 @@ import {
IslandPill, IslandPill,
AgentDotGrid, AgentDotGrid,
CALM, CALM,
WARM,
ACCENT, ACCENT,
CRITICAL, CRITICAL,
ModuleCard, ModuleCard,
@@ -19,14 +21,14 @@ import { Campfire, type CampfireState } from "./Campfire";
import { import {
ApprovalCardMock, ApprovalCardMock,
SessionRowMock, SessionRowMock,
Glyph,
CLAUDE, CLAUDE,
CODEX CODEX
} from "./ModuleMocks"; } from "./ModuleMocks";
import { useDemoLoop } from "./useDemoLoop"; import { useDemoLoop } from "./useDemoLoop";
import { BatteryDemo, CalendarDemo, MixerDemo } from "./UtilityDemos";
import { import {
TerminalReplyDemo, TerminalReplyDemo,
RemoteHostDemo, MixerDemo,
CaptureDemo, CaptureDemo,
FloatingActionsDemo FloatingActionsDemo
} from "./MoreDemos"; } from "./MoreDemos";
@@ -35,7 +37,7 @@ import {
* Detailed feature breakdowns: alternating copy + live-demo sections. * Detailed feature breakdowns: alternating copy + live-demo sections.
* Each demo stages the real interaction loop: attention flares, approval * Each demo stages the real interaction loop: attention flares, approval
* resolves, fans throttle, the charge ceiling holds, meetings surface, * resolves, fans throttle, the charge ceiling holds, meetings surface,
* the camera wakes. Newer demos live in * the camera wakes, a remote host goes live. Newer demos live in
* MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx. * MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx.
*/ */
@@ -168,11 +170,11 @@ function FleetDemo() {
{phase() === "flare" {phase() === "flare"
? "Codex needs your input" ? "Codex needs your input"
: phase() === "resolve" : phase() === "resolve"
? "Reply sent. Agents running." ? "Answer delivered — agents back to work"
: "Agents running"} : "Fleet running calmly"}
</p> </p>
<p class="mt-0.5 text-[11px] text-white/50"> <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> </p>
</div> </div>
</div> </div>
@@ -234,7 +236,7 @@ function ApprovalDemo() {
prompt="harden the bridge" prompt="harden the bridge"
state={ state={
asking() asking()
? "Approve Bash: swift test --filter Bridge?" ? "Approve Bash — swift test --filter Bridge?"
: "Running swift test --filter Bridge…" : "Running swift test --filter Bridge…"
} }
status={asking() ? "attention" : "running"} status={asking() ? "attention" : "running"}
@@ -408,6 +410,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 ──────────────── */ /* ── Demo 6: the lens only wakes for a click ──────────────── */
function CameraDemo() { function CameraDemo() {
@@ -490,14 +799,120 @@ function CameraDemo() {
</div> </div>
<p class="text-[10px] text-white/40"> <p class="text-[10px] text-white/40">
{phase() === "live" {phase() === "live"
? "Camera on while this panel is open" ? "live — stops the moment you leave the panel"
: "Camera off until you click Start"} : "inert — the lens stays dark until you click"}
</p> </p>
</div> </div>
</ModuleCard> </ModuleCard>
); );
} }
/* ── Demo 7: Settings ▸ Remote hosts — one click to Live ──────────── */
/** RemoteMirror's progress steps, in order. */
const SETUP_STEPS = [
"connecting",
"pushing the hook binary",
"configuring agents",
"checking the host can reach this Mac"
];
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)]
]);
const live = () => step() === 4;
const working = () => step() >= 0 && step() < 4;
const chip = () =>
live()
? { color: "rgb(48,209,88)", label: "Live" }
: working()
? { color: "rgba(255,255,255,0.55)", label: "Setting up" }
: { color: "rgba(255,255,255,0.35)", label: "Not set up" };
return (
<div class="relative mx-auto w-full max-w-[360px] space-y-3">
{/* 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}
</span>
<Show
when={live()}
fallback={
<span
class="rounded-md px-2.5 py-1 text-[11px] font-semibold text-white transition-all duration-200"
style={{
background: ACCENT,
opacity: working() ? 0 : 1,
transform: pressed() ? "scale(0.92)" : "scale(1)"
}}
>
Set up
</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="h-4 text-[11px] text-white/50">
{working() ? SETUP_STEPS[step()] : " "}
</p>
</div>
</ModuleCard>
{/* its agents arrive as ordinary rows, the machine named in the chip */}
<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"
origin="iTerm2"
age="now"
/>
</div>
<FakeCursor
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
at={{ x: 88, y: 16 }}
/>
</div>
);
}
/* ── Demo 8: campfire states ───────────────────────────────────────── */ /* ── Demo 8: campfire states ───────────────────────────────────────── */
function CampfireDemo() { function CampfireDemo() {
@@ -509,11 +924,11 @@ function CampfireDemo() {
"error" "error"
]; ];
const labels = [ const labels = [
"Running", "fleet running",
"Question", "pending question",
"Ready", "ready to review",
"Idle", "all quiet",
"Error" "something failed"
]; ];
return ( return (
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5"> <div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
@@ -541,13 +956,13 @@ export default function FeatureBreakdowns() {
<div class="space-y-24"> <div class="space-y-24">
<BreakdownSection <BreakdownSection
kicker="The island" kicker="The island"
title="See which agents need your attention" title="Your whole fleet, in one pixel strip"
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." body="Every session, every agent, one square each. The pill never wraps, never grows — an arriving question flashes the fire and swaps the slot, then hands the notch back."
bullets={[ bullets={[
"Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, Cursor and more", "18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
"Connect custom agents using the published integration contract", "Custom agents: your own coding agent can wire itself in from the published contract",
"Choose which information appears in the compact view", "Slot rankings — you choose which data earns the notch",
"Works on displays without a notch, centered at the top of the screen" "You don't need a notch — external displays get the same island, floating top-center"
]} ]}
> >
<FleetDemo /> <FleetDemo />
@@ -555,12 +970,13 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Approvals" kicker="Approvals"
title="Approve agent requests" title="Answer from the island — or your phone"
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." body="A gated tool call surfaces the island: the fire turns to a question, the card pins above your sessions, and the row says what it's waiting on. Allow, Allow for session, Deny, or deny with a reason, and the run picks up where it stopped."
bullets={[ bullets={[
"Review permissions above the session list and answer questions in the session row", "Approval cards pin above the session list; questions answer inside their row",
"Allow for session approves subsequent requests in that session", "Allow for session waves through the rest of that session's requests",
"Respond from iPhone or Apple Watch with the TestFlight beta", "Answer from iPhone or Apple Watch too, now in TestFlight beta",
"Works across local and remote sessions; Muse, Cline and Amp are this Mac only"
]} ]}
reversed reversed
> >
@@ -569,8 +985,8 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Terminals" kicker="Terminals"
title="Reply to agents and open their terminals" title="Reply without hunting for the tab"
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." 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={[ bullets={[
"Replies reach agents in tmux, iTerm2, Terminal, and kitty", "Replies reach agents in tmux, iTerm2, Terminal, and kitty",
"Jump focuses the exact window, even for sessions over SSH", "Jump focuses the exact window, even for sessions over SSH",
@@ -582,13 +998,12 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Remote" kicker="Remote"
title="Set up remote agents in one click" title="Any SSH box, one click away"
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." body="Pick a host from your ssh config or your tailnet and click once — the nook-hook installs itself and its agents appear beside your local ones, indistinguishable."
bullets={[ bullets={[
"Local and remote sessions in the same island, labelled by host", "One click: connect, push the hook, configure agents, check it can reach this Mac",
"Remote integrations follow the agents installed on this Mac", "Hosts mirror this Mac's installed integrations automatically",
"Direct connections over Tailscale, or an SSH tunnel for other hosts", "Remote sessions are ordinary rows; the agent chip names the machine"
"Uses the host\'s existing SSH authorization"
]} ]}
reversed reversed
> >
@@ -597,10 +1012,10 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="The campfire" kicker="The campfire"
title="Agent status in the campfire" title="A hearth that tells the truth"
body="The pixel-art campfire changes with your agents' activity. Different animations indicate work in progress, a question waiting for you, or an error." 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={[ 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", "Hand-drawn 14×14 sprites, no tweening",
"Six chiptunes you can assign to any agent event" "Six chiptunes you can assign to any agent event"
]} ]}
@@ -610,13 +1025,13 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Cooling" kicker="Cooling"
title="Monitor and control your Mac's fans" title="Fans that answer to you, not the scheduler"
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." 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={[ bullets={[
"Switch between Stock, Aggressive, and MAX", "Stock, Aggressive, and MAX — swap curves in one click",
"See active MAX and COOL sessions in the compact view", "MAX and COOL sessions surface right in the pill",
"CPU and GPU temperature readings change color as they rise", "CPU/GPU die temps tier-color before things get loud",
"Hardware monitoring runs locally on your Mac" "All local: your hardware data never leaves the device"
]} ]}
reversed reversed
> >
@@ -625,14 +1040,14 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Power" kicker="Power"
title="Set a battery charge limit" title="Wall power, on your terms"
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." 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={[ bullets={[
"Drag the limit marker or type a percentage; available limits depend on your Mac", "One slider, 50–100%, on any macOS version",
"Uses macOS charge limits where available, with helper-based control on supported Macs", "Hooks the system limiter where it exists, enforces elsewhere",
"Top-up mode allows charging past the limit and turns off when you unplug", "Top-up mode charges past, then re-arms on unplug",
"Overheat protection can pause charging when the battery gets hot", "Thermal protection pauses charging when the pack runs hot",
"Separate readings for system draw and battery charging, not a wall-power measurement" "Energy flow figure: adapter → battery → system, live watts"
]} ]}
> >
<BatteryDemo /> <BatteryDemo />
@@ -640,12 +1055,12 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Sound" kicker="Sound"
title="Adjust volume and output for each app" title="Every app gets its own fader"
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." 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={[ bullets={[
"Per-app volume, mute, and output routing", "Per-app volume, mute, and output routing",
"Master output, system sounds, and microphone pickers in one panel", "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 reversed
> >
@@ -654,8 +1069,8 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Capture" kicker="Capture"
title="Take screenshots and record your screen" title="Screenshots and recordings from the notch"
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." 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={[ bullets={[
"Area, screen, or single-app capture", "Area, screen, or single-app capture",
"Screenshots save to your screenshot folder and copy to the clipboard", "Screenshots save to your screenshot folder and copy to the clipboard",
@@ -668,13 +1083,13 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Your day" kicker="Your day"
title="Check your calendar and join meetings" title="Meetings come to the notch"
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." 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={[ bullets={[
"Date, countdown, and meeting progress in the compact view", "Next meeting counts down right in the slot",
"Choose how early the island opens before a meeting", "Near a call, the island opens the day itself",
"Join meetings from the video button beside an event", "Meeting links get an Open button in the row",
"Uses your Mac's calendars and reminders" "Real calendars and reminders — no sync service"
]} ]}
reversed reversed
> >
@@ -683,13 +1098,13 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Camera" kicker="Camera"
title="Preview your camera before a call" title="A lens that stays asleep"
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." 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={[ bullets={[
"Requires camera permission and a click on Start camera", "Nothing watches until Start camera gets a click",
"Preview runs only while the panel is open", "Live only while the panel sits open",
"A device picker for every connected camera", "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 /> <CameraDemo />
@@ -697,8 +1112,8 @@ export default function FeatureBreakdowns() {
<BreakdownSection <BreakdownSection
kicker="Floating actions" kicker="Floating actions"
title="Add shortcuts beside the island" title="Your buttons, orbiting 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." body="Pin up to seven one-tap actions around the expanded island. They ride its expansion in and out, so the things you reach for most are always one click away."
bullets={[ bullets={[
"Run an Apple Shortcut, open an app or URL, or run a shell command", "Run an Apple Shortcut, open an app or URL, or run a shell command",
"Toggle Low Power Mode, top-up, or overheat protection", "Toggle Low Power Mode, top-up, or overheat protection",

View File

@@ -1,6 +1,12 @@
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js"; import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock"; import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
import { ApprovalCardMock, SessionRowMock, DieLine, CLAUDE, CODEX } from "./ModuleMocks"; import {
ApprovalCardMock,
SessionRowMock,
DieLine,
CLAUDE,
CODEX
} from "./ModuleMocks";
/** /**
* Feature collage: five cells sold as the app's actual UI. The hero cell * Feature collage: five cells sold as the app's actual UI. The hero cell
@@ -99,6 +105,10 @@ function FansPanelMock() {
); );
} }
/**
* Remote agents as the panel draws them: ordinary session rows whose agent
* chip names the machine ("Claude Code · build-box").
*/
function RemoteMock() { function RemoteMock() {
return ( return (
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -162,7 +172,7 @@ export default function FeatureCollage() {
Why the Nook Why the Nook
</p> </p>
<h2 class="text-text mb-12 text-center text-3xl font-bold"> <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> </h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-3"> <div class="grid grid-cols-1 gap-5 md:grid-cols-3">
@@ -173,12 +183,12 @@ export default function FeatureCollage() {
The island The island
</p> </p>
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight"> <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> </h3>
<p class="text-subtext0 max-w-sm text-sm leading-relaxed"> <p class="text-subtext0 max-w-sm text-sm leading-relaxed">
The island sits around your Mac's notch, with agent activity on A pill tucked into your notch — a campfire for your whole fleet
the left and your choice of information on the right. Blue means on the left, live data on the right. Blue running, green ready,
running, green means ready, and amber means an agent needs you. amber needs you.
</p> </p>
</div> </div>
<div class="my-10 flex w-full justify-center"> <div class="my-10 flex w-full justify-center">
@@ -211,15 +221,15 @@ export default function FeatureCollage() {
/> />
</div> </div>
<p class="text-subtext0 -mb-1 text-center text-xs"> <p class="text-subtext0 -mb-1 text-center text-xs">
Open the island to see your sessions expand for the full panel ↓
</p> </p>
</div> </div>
{/* Approvals */} {/* Approvals */}
<CollageCell <CollageCell
kicker="Approvals" kicker="Approvals"
title="Review permission requests" title="Unblock agents, anywhere"
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." body="Permission prompts pin as cards above your sessions — Allow, Allow for session, or Deny. Away from the desk, answer from your iPhone or Apple Watch (beta)."
> >
<InkStage> <InkStage>
<div class="w-full max-w-sm px-3"> <div class="w-full max-w-sm px-3">
@@ -236,8 +246,8 @@ export default function FeatureCollage() {
{/* Fans */} {/* Fans */}
<CollageCell <CollageCell
kicker="Cooling" kicker="Cooling"
title="Fan speeds and cooling modes" title="Silence it or max it"
body="Monitor each fan's speed and choose a cooling mode. Keep automatic control or start a MAX session before a demanding task." 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"> <InkStage class="w-full">
<div class="w-full max-w-[260px] text-left"> <div class="w-full max-w-[260px] text-left">
@@ -246,11 +256,24 @@ export default function FeatureCollage() {
</InkStage> </InkStage>
</CollageCell> </CollageCell>
{/* Remote agents */}
<CollageCell
kicker="Remote"
title="Agents on other machines"
body="Run agents on Linux boxes and SSH servers — one click installs the nook-hook, and their sessions join the same island."
>
<InkStage class="w-full">
<div class="w-full px-3 text-left">
<RemoteMock />
</div>
</InkStage>
</CollageCell>
{/* Gauges */} {/* Gauges */}
<CollageCell <CollageCell
kicker="Your Mac" kicker="Your Mac"
title="CPU and GPU monitoring" title="The gauges that matter"
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." body="Each die's temperature beside its 60-second usage trace, tinted amber from 70° and red at 85°, so you see a compile heating up before the fans do."
> >
<InkStage class="w-full"> <InkStage class="w-full">
<div class="w-full max-w-[300px] text-left"> <div class="w-full max-w-[300px] text-left">
@@ -258,27 +281,15 @@ export default function FeatureCollage() {
</div> </div>
</InkStage> </InkStage>
</CollageCell> </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> </div>
<p class="text-subtext1 mt-6 text-center text-xs"> <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 <a
href="#island-extras" href="#island-extras"
class="underline decoration-dotted hover:opacity-80" class="underline decoration-dotted hover:opacity-80"
> >
Browse the other modules see them all ↓
</a> </a>
</p> </p>
</div> </div>

View File

@@ -13,6 +13,7 @@ import {
ACCENT, ACCENT,
CALM, CALM,
WARM, WARM,
CRITICAL,
CARD_FILL, CARD_FILL,
CARD_STROKE CARD_STROKE
} from "./IslandMock"; } from "./IslandMock";
@@ -570,8 +571,8 @@ function KeepAwakeMock() {
const MODES = [ const MODES = [
{ name: "Auto", note: "follows each agent's own settings", card: "asks", tint: "rgba(234,179,8,0.10)" }, { 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: "Yolo", note: "gated tools pass straight through", 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: "Verify", note: "every gated tool waits for you", card: "held", tint: "rgba(229,72,77,0.12)" }
]; ];
function ApprovalModesMock() { function ApprovalModesMock() {
@@ -721,7 +722,7 @@ function LayoutMock() {
/* ── Marquee ─────────────────────────────────────────────────────── */ /* ── Marquee ─────────────────────────────────────────────────────── */
const CHIPS_A = [ const CHIPS_A = [
"Agents wired in", "18 agents",
"Custom agents", "Custom agents",
"Six chiptunes", "Six chiptunes",
"Focus-aware slots", "Focus-aware slots",
@@ -739,6 +740,7 @@ const CHIPS_B = [
"tmux · iTerm2 · kitty", "tmux · iTerm2 · kitty",
"Markdown replies", "Markdown replies",
"Tailscale hosts", "Tailscale hosts",
"One-click SSH setup",
"iPhone widgets", "iPhone widgets",
"Watch-face widgets", "Watch-face widgets",
"Screenshots to clipboard", "Screenshots to clipboard",
@@ -772,14 +774,14 @@ export default function IslandExtras() {
<section class="bg-base relative z-20 py-16" id="island-extras"> <section class="bg-base relative z-20 py-16" id="island-extras">
<div class="px-4 md:px-8"> <div class="px-4 md:px-8">
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase"> <p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
More modules And everything else
</p> </p>
<h2 class="text-text mb-3 text-center text-3xl font-bold"> <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> </h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl text-center leading-relaxed"> <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. Every module below lives in the same notch, arranged however you
They're all included with your license. like, under the same one-time license.
</p> </p>
</div> </div>
@@ -792,8 +794,8 @@ export default function IslandExtras() {
<CollageCell <CollageCell
class="md:col-span-2" class="md:col-span-2"
kicker="Now Playing" kicker="Now Playing"
title="Playback controls and track information" title="Music in the notch"
body="See what's playing and control playback. For sources without artwork, including some browsers, the compact view shows an audio waveform." 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"> <InkStage class="h-full w-full items-center">
<NowPlayingMock /> <NowPlayingMock />
@@ -802,8 +804,8 @@ export default function IslandExtras() {
<CollageCell <CollageCell
kicker="Timers" kicker="Timers"
title="Run several timers at once" title="Timers that stack"
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." 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"> <InkStage class="h-full w-full items-center px-3">
<TimersMock /> <TimersMock />
@@ -812,8 +814,8 @@ export default function IslandExtras() {
<CollageCell <CollageCell
kicker="Weather" kicker="Weather"
title="Weather for your saved places" title="The sky, in the slot"
body="Check hourly and daily forecasts for saved places. The compact view shows the current conditions and temperature." 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"> <InkStage class="h-full w-full items-center px-3">
<WeatherMock /> <WeatherMock />
@@ -823,8 +825,8 @@ export default function IslandExtras() {
<CollageCell <CollageCell
class="md:col-span-2" class="md:col-span-2"
kicker="Interceptions" kicker="Interceptions"
title="Volume and brightness controls" title="Volume and brightness, under the notch"
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." 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"> <InkStage class="h-full w-full items-center">
<div class="flex min-h-[110px] w-full justify-center pt-2"> <div class="flex min-h-[110px] w-full justify-center pt-2">
@@ -835,8 +837,8 @@ export default function IslandExtras() {
<CollageCell <CollageCell
kicker="Approval modes" kicker="Approval modes"
title="Choose how approvals are handled" title="One stance for every agent"
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." 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"> <InkStage class="h-full w-full items-center px-3">
<ApprovalModesMock /> <ApprovalModesMock />
@@ -845,8 +847,8 @@ export default function IslandExtras() {
<CollageCell <CollageCell
kicker="Network" kicker="Network"
title="Network information and speed tests" title="Know your connection"
body="View and copy your public IP, ISP, gateway, and DNS details. Check for VPN routes or run a speed test." 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"> <InkStage class="h-full w-full items-center px-3">
<NetworkMock /> <NetworkMock />
@@ -855,8 +857,8 @@ export default function IslandExtras() {
<CollageCell <CollageCell
kicker="Disks" kicker="Disks"
title="Disk usage and drive health" title="Storage, measured"
body="Check read and write speeds, free space, and SMART health information for supported drives." 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"> <InkStage class="h-full w-full items-center px-3">
<DiskMock /> <DiskMock />
@@ -865,8 +867,8 @@ export default function IslandExtras() {
<CollageCell <CollageCell
kicker="Keep Awake" kicker="Keep Awake"
title="Keep your Mac awake" title="Sleep when you say"
body="Keep your Mac awake for a set time, while plugged in, with the lid closed, or while your agents are working." 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"> <InkStage class="h-full w-full items-center px-3">
<KeepAwakeMock /> <KeepAwakeMock />
@@ -875,7 +877,7 @@ export default function IslandExtras() {
<CollageCell <CollageCell
kicker="Lock Screen" 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." 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"> <InkStage class="h-full w-full items-center px-3">
@@ -885,8 +887,8 @@ export default function IslandExtras() {
<CollageCell <CollageCell
kicker="Layout" kicker="Layout"
title="Arrange your panels" title="Panels you shape"
body="Drag and resize modules, choose how much detail they show, and change compact-view priorities with your Focus mode." 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"> <InkStage class="h-full w-full items-center px-3">
<LayoutMock /> <LayoutMock />

View File

@@ -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" /> <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" />, 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: () => ( 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" /> <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" />
) )

View File

@@ -7,20 +7,21 @@ import {
STATUS, STATUS,
ACCENT, ACCENT,
CALM, CALM,
CRITICAL,
CARD_STROKE CARD_STROKE
} from "./IslandMock"; } from "./IslandMock";
import { useDemoLoop } from "./useDemoLoop"; 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: * 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. * floating actions. Same ink chrome and status palette as the originals.
*/ */
/* ── Reply lands in the agent's own terminal ──────────────────────── */ /* ── 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() { export function TerminalReplyDemo() {
const [typed, setTyped] = createSignal(0); const [typed, setTyped] = createSignal(0);
@@ -152,106 +153,124 @@ export function TerminalReplyDemo() {
); );
} }
/** RemoteMirror's progress steps, in order. */ /* ── Volume mixer: every app its own level and output ─────────────── */
const SETUP_STEPS = [
"connecting", interface MixerApp {
"pushing the hook binary", name: string;
"configuring agents", color: string;
"checking the host can reach this Mac" 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() { export function MixerDemo() {
/* -1 not set up · 0…3 setting up · 4 live */ const [step, setStep] = createSignal(0);
const [step, setStep] = createSignal(-1); useDemoLoop(9600, [
const [pressed, setPressed] = createSignal(false); [0, () => setStep(0)],
useDemoLoop(11000, [ [1800, () => setStep(1)],
[0, () => (setStep(-1), setPressed(false))], [3800, () => setStep(2)],
[1400, () => setPressed(true)], [5800, () => setStep(3)]
[1700, () => (setPressed(false), setStep(0))],
[2700, () => setStep(1)],
[3700, () => setStep(2)],
[4700, () => setStep(3)],
[5800, () => setStep(4)]
]); ]);
const live = () => step() === 4;
const working = () => step() >= 0 && step() < 4; const level = (i: number) => {
const chip = () => if (i === 0) return step() >= 3 ? 0.38 : 0.72;
live() if (i === 1) return 0.55;
? { color: STATUS.ready, label: "Live" } if (i === 2) return 0.86;
: working() return 0.46;
? { color: "rgba(255,255,255,0.55)", label: "Setting up" } };
: { color: "rgba(255,255,255,0.35)", label: "Not set up" }; const muted = (i: number) => i === 1 && step() >= 1;
const route = (i: number) =>
i === 0 && step() >= 2 ? "Headphones" : i === 2 ? "Display" : "Speakers";
return ( 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"> <ModuleCard class="mx-auto w-full max-w-[340px]">
{/* the host's card in Settings ▸ Agents ▸ Remote hosts */} <div class="space-y-2.5 text-left">
<ModuleCard> <div class="flex items-center">
<div class="space-y-2 text-left"> <span class="text-[11px] font-semibold text-white/50">
<div class="flex items-center gap-2.5"> Volume mixer
<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}
</span> </span>
<Show <span class="ml-auto font-mono text-[10px] text-white/40">
when={live()} out · Speakers
fallback={ </span>
</div>
<For each={MIXER_APPS}>
{(app, i) => (
<div class="flex items-center gap-2">
<span <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={{ style={{
background: ACCENT, background:
opacity: working() ? 0 : 1, i() === 0 && step() === 2
transform: pressed() ? "scale(0.92)" : "scale(1)" ? `${ACCENT}55`
: "rgba(255,255,255,0.07)"
}} }}
> >
Set up {route(i())}
</span> </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> </div>
<p class="min-h-8 text-[11px] text-white/50"> <div
{working() ? SETUP_STEPS[step()] : " "} 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> </p>
</div> </div>
</ModuleCard> </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>
); );
} }

View File

@@ -96,7 +96,7 @@ function CompanionDemo() {
{allowed() ? "Allowed ✓" : "Claude Code · the-nook"} {allowed() ? "Allowed ✓" : "Claude Code · the-nook"}
</p> </p>
<p class="text-[9px] leading-snug text-white/60"> <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> </p>
<Show when={!allowed()}> <Show when={!allowed()}>
<div class="mt-1.5 flex gap-1"> <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="text-[8px] font-semibold text-white/50">Codex asks</p>
<p class="mt-0.5 text-[9.5px] leading-snug font-semibold text-white"> <p class="mt-0.5 text-[9.5px] leading-snug font-semibold text-white">
Deploy to staging? Ship it to staging?
</p> </p>
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
<For each={["Yes", "Not yet"]}> <For each={["Yes", "Not yet"]}>
@@ -194,19 +194,19 @@ export default function PhoneBeta(props: { brandColor: string }) {
TestFlight beta TestFlight beta
</span> </span>
<h2 class="text-text mb-3 text-3xl font-bold tracking-tight"> <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> </h2>
<p class="text-subtext0 mb-5 leading-relaxed"> <p class="text-subtext0 mb-5 leading-relaxed">
The companion app is in TestFlight beta. Check your agents, The iPhone and Apple Watch companion is in beta. Approvals,
respond to permission requests, and answer questions when you're questions, and replies reach you away from the desk, and your
away from your Mac. answer goes straight back to the agent.
</p> </p>
<ul class="mb-8 space-y-2.5"> <ul class="mb-8 space-y-2.5">
<For <For
each={[ each={[
"Allow, deny, answer, and reply from iPhone or Apple Watch", "Allow, deny, answer, and reply from iPhone or Apple Watch",
"Connects over an end-to-end encrypted relay, your tailnet, or your LAN", "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" "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> </form>
<Show when={state() === "sent"}> <Show when={state() === "sent"}>
<p class="text-subtext1 mt-4 text-sm"> <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> </p>
</Show> </Show>
<Show when={state() === "limited"}> <Show when={state() === "limited"}>

View File

@@ -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>
);
}

View File

@@ -67,8 +67,7 @@ describe("generateSitemap", () => {
expect(locs).toContain("https://nessa.freno.me/"); expect(locs).toContain("https://nessa.freno.me/");
expect(locs).toContain("https://nessa.freno.me/contact"); expect(locs).toContain("https://nessa.freno.me/contact");
expect(locs).toContain("https://nessa.freno.me/privacy"); expect(locs).toContain("https://nessa.freno.me/privacy");
expect(locs).toContain("https://nessa.freno.me/terms"); expect(locs.length).toBe(3);
expect(locs.length).toBe(4);
for (const loc of locs) { for (const loc of locs) {
expect(loc).not.toContain("://freno.me/"); expect(loc).not.toContain("://freno.me/");

View File

@@ -57,8 +57,7 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
nessa: [ nessa: [
{ path: "/", changefreq: "weekly", priority: 1.0 }, { path: "/", changefreq: "weekly", priority: 1.0 },
{ path: "/contact", changefreq: "monthly", priority: 0.6 }, { path: "/contact", changefreq: "monthly", priority: 0.6 },
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }, { path: "/privacy", changefreq: "yearly", priority: 0.4 }
{ path: "/terms", changefreq: "yearly", priority: 0.4 }
], ],
lineage: [ lineage: [

View File

@@ -1,7 +1,6 @@
import { ContactForm } from "~/components/ContactForm"; import { ContactForm } from "~/components/ContactForm";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders"; import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader"; import SubdomainHeader from "~/components/SubdomainHeader";
import { SUPPORT_EMAIL } from "./content";
/** /**
* Nessa contact page (`nessa.freno.me/contact`). * 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 * Acceptance: `nessa.localhost:3000/contact` renders the contact form with
* Nessa branding; submissions email `michael@freno.me` with subject * Nessa branding; submissions email `michael@freno.me` with subject
* `[Nessa] Contact Request`. The support address shown by the app is also * `[Nessa] Contact Request`.
* published here, so the contact route does not depend on the form alone
* (FRE-1670).
*/ */
export default function NessaContactPage() { export default function NessaContactPage() {
return ( return (
<> <>
<EdgeCacheHeaders maxAge={60} staleSeconds={3600} /> <EdgeCacheHeaders maxAge={60} staleSeconds={3600} />
<SubdomainHeader /> <SubdomainHeader />
<ContactForm> <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>
</> </>
); );
} }

View File

@@ -14,15 +14,6 @@ export const TAGLINE = "The fitness app that puts you first." as const;
export const SUBTITLE = export const SUBTITLE =
"Track, train, and connect — without the paywall." as const; "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 = export const ICON_DEFAULT =
"/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const; "/Nessa Exports/Nessa-iOS-Default-1024x1024.png" as const;
export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const; export const ICON_DARK = "/Nessa Exports/Nessa-iOS-Dark-1024x1024.png" as const;

View File

@@ -33,7 +33,6 @@ import {
DELETION_GRACE_PERIOD_LABEL, DELETION_GRACE_PERIOD_LABEL,
PAGE_META PAGE_META
} from "~/routes/nessa/deletion-content"; } from "~/routes/nessa/deletion-content";
import { SUPPORT_EMAIL } from "./content";
export default function NessaDeletionPage() { export default function NessaDeletionPage() {
return ( return (
@@ -55,18 +54,6 @@ export default function NessaDeletionPage() {
removed. No data related to the account is retained in any way. removed. No data related to the account is retained in any way.
</div> </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 <DeletionForm
product={DELETION_PRODUCT_KEY} product={DELETION_PRODUCT_KEY}
cookieName={DELETION_COOKIE_NAME} cookieName={DELETION_COOKIE_NAME}

View File

@@ -30,7 +30,6 @@ import { A } from "@solidjs/router";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders"; import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
import SubdomainHeader from "~/components/SubdomainHeader"; import SubdomainHeader from "~/components/SubdomainHeader";
import { SUPPORT_EMAIL } from "./content";
export default function NessaPrivacyPolicy() { export default function NessaPrivacyPolicy() {
return ( return (
@@ -175,29 +174,15 @@ export default function NessaPrivacyPolicy() {
<span class="-ml-4 pr-2">5.</span> Contact Us <span class="-ml-4 pr-2">5.</span> Contact Us
</div> </div>
<div class="pb-2 pl-4"> <div class="pb-2 pl-4">
<div class="-ml-6">(a) Data Controller:</div> Nessa is operated by <div class="-ml-6">(a) Reaching Out:</div> If there are any
FrenoCorp. For the purposes of applicable data-protection law, questions or comments regarding this privacy policy, you can
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
contact us{" "} contact us{" "}
<A href="/contact" class="text-blue hover-underline-animation"> <A href="/contact" class="text-blue hover-underline-animation">
through the contact form here
</A>{" "} </A>
or email{" "}
<a
href={`mailto:${SUPPORT_EMAIL}`}
class="text-blue hover-underline-animation"
>
{SUPPORT_EMAIL}
</a>
. .
</div> </div>
</div> </div>
</div>
</ol> </ol>
</div> </div>
</> </>

View File

@@ -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");
});
});

View File

@@ -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&apos;s Terms of Use</div>
<div class="py-2">Last Updated: November 9, 2026</div>
<div class="py-2">
Welcome to Nessa (&apos;We&apos;, &apos;Us&apos;, &apos;Our&apos;).
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&apos;s or guardian&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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&apos;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 &quot;as
is&quot; and &quot;as available&quot; 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&apos;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 &quot;Last Updated&quot; 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>
</>
);
}

View File

@@ -1,6 +1,5 @@
/** /**
* Regression test for the /agents roster (NOOK-336) and its coverage wording * Regression test for the /agents roster and its coverage count.
* (FRE-359).
* *
* The page mirrors the app's registry, `AgentSpec.all` in * The page mirrors the app's registry, `AgentSpec.all` in
* the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined * the-nook/Sources/NookCore/Agents/AgentRegistry.swift. When Muse joined
@@ -9,11 +8,11 @@
* them. The registry lives in another repo, so the expected roster is * them. The registry lives in another repo, so the expected roster is
* restated here; when an agent is added there, add it here and on the page. * restated here; when an agent is added there, add it here and on the page.
* *
* FRE-359 verdict B: the page no longer states a count or claims coverage * FRE-997 restored the count. The page said thirteen while the registry held
* ("all thirteen", "every coding agent The Nook supports"). The page still * eighteen, so the count was dropped rather than corrected (FRE-359 verdict B);
* lists 13 of the registry's 18 rows — the table is hand-mirrored and its * now that all eighteen rows are on the page, the roster check is an equality
* regeneration is its own ticket — so the roster check here is a subset * check again and the page states eighteen. A subset check would let a shipped
* check in registry order, not an equality check. * agent go missing behind a passing suite, which is the NOOK-336 failure.
* *
* STATIC SOURCE AUDIT, the same pattern as the downloads regressions. * STATIC SOURCE AUDIT, the same pattern as the downloads regressions.
*/ */
@@ -26,6 +25,9 @@ const SOURCE = readFileSync(join(import.meta.dir, "agents.tsx"), "utf8");
/** User-facing copy only: comments are not copy and may name the old wording. */ /** User-facing copy only: comments are not copy and may name the old wording. */
const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); const COPY = SOURCE.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
/** JSX wraps prose across lines; match a sentence, not its indentation. */
const PROSE = COPY.replace(/\s+/g, " ");
/** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */ /** AgentSpec.all display names, in registry order (18 rows). @ The-Nook f4258034, 2026-10-29 */
const REGISTRY = [ const REGISTRY = [
"Claude Code", "Claude Code",
@@ -48,51 +50,41 @@ const REGISTRY = [
"Amp" "Amp"
]; ];
const NUMBER_WORDS = ["twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen"];
const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]); const entries = [...SOURCE.matchAll(/^\s{4}name: "([^"]+)",$/gm)].map((m) => m[1]);
/** The page's rows are a subset of the registry, in registry order. */ describe("Nook /agents roster", () => {
function subsetInOrder(page: string[], registry: string[]): boolean { it("lists every registry agent, once, in registry order", () => {
let cursor = 0; expect(entries).toEqual(REGISTRY);
for (const name of page) {
const at = registry.indexOf(name, cursor);
if (at === -1) return false;
cursor = at + 1;
}
return true;
}
describe("Nook /agents roster (NOOK-336)", () => {
it("lists only registry agents, in registry order", () => {
expect(entries.length).toBeGreaterThan(0);
expect(subsetInOrder(entries, REGISTRY)).toBe(true);
}); });
it("lists each agent at most once", () => { it("states the registry's size", () => {
expect(new Set(entries).size).toBe(entries.length); expect(entries.length).toBe(18);
expect(COPY).toContain("all eighteen");
expect(COPY).toContain("The 18 agents");
expect(COPY).toContain("eighteen coding agents");
});
it("carries no stale count", () => {
expect(COPY.toLowerCase()).not.toContain("thirteen");
expect(COPY).not.toMatch(/\b13 agents\b/);
expect(COPY).not.toContain("all thirteen");
}); });
}); });
describe("Nook /agents coverage wording (FRE-359)", () => { describe("Nook /agents remote coverage", () => {
it("states no agent count anywhere on the page", () => { it("says agents can run on remote machines over SSH", () => {
for (const word of NUMBER_WORDS) { expect(PROSE).toContain("remote machines");
expect(COPY.toLowerCase()).not.toContain(word); expect(PROSE).toContain("remote sessions report into the same grid");
}
// A bare numeral count ("13 agents", "The 13") is just as precise and just
// as unverified; the page must not state one in any form.
expect(COPY).not.toMatch(/\b\d{1,2} agents\b/);
expect(COPY).not.toMatch(/\ball \d{1,2}\b/);
}); });
it("makes no completeness claim over the agent set", () => { it("names the three agents that stay on this Mac", () => {
for (const banned of [ expect(PROSE).toContain("Muse, Cline and Amp run on this Mac only");
"every coding agent", });
"the agents the nook supports",
"agents the nook supports", it("discloses the local-only rows where they are listed", () => {
"supported agents" const localOnly = [
]) { ...SOURCE.matchAll(/This Mac only — remote hosts skip it/g)
expect(COPY.toLowerCase()).not.toContain(banned); ];
} expect(localOnly.length).toBe(3);
}); });
}); });

View File

@@ -1,11 +1,10 @@
/** /**
* `/agents` on the Nook subdomain — the agent roster and each row's event * `/agents` on the Nook subdomain — all eighteen agents in the registry and
* coverage. It states no agent count and claims no completeness over the * each row's event coverage.
* registry (FRE-359 verdict B).
* *
* The registry below mirrors `AgentSpec.all` in the app * The registry below mirrors `AgentSpec.all` in the app
* (~/Code/the-nook/Sources/NookCore/Agents/AgentSpec.swift) and each agent's * (~/Code/the-nook/Sources/NookCore/Agents/AgentRegistry.swift) and each
* installed hook surface (hook/setup.go) or plugin event subscription * agent's installed hook surface (hook/setup.go) or plugin event subscription
* (plugins/*), normalized onto the shared event vocabulary * (plugins/*), normalized onto the shared event vocabulary
* (~/Code/the-nook/docs/agent-interface.md). Copy follows * (~/Code/the-nook/docs/agent-interface.md). Copy follows
* marketing/positioning.md: category nouns, honest scoping, no unverifiable * marketing/positioning.md: category nouns, honest scoping, no unverifiable
@@ -210,7 +209,8 @@ const AGENTS: AgentEntry[] = [
}, },
{ {
name: "Cline", name: "Cline",
wiring: "One hook file per event in its hooks directory", wiring:
"One hook file per event in its hooks directory. This Mac only — remote hosts skip it",
events: [ events: [
"SessionStart", "SessionStart",
"SessionEnd", "SessionEnd",
@@ -261,6 +261,93 @@ const AGENTS: AgentEntry[] = [
], ],
gating: gating:
"Tool calls can be allowed or denied from a card; permission prompts stay with the agent" "Tool calls can be allowed or denied from a card; permission prompts stay with the agent"
},
{
name: "Kimi",
wiring: "Hook rules in its TOML config, one per event",
events: [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"AgentStart",
"PreToolUse",
"PermissionRequest",
"PermissionResolved",
"PostToolUse",
"PostToolUseFailure",
"Stop",
"SessionError",
"SubagentStart",
"SubagentStop",
"PreCompact",
"Notification"
],
gating:
"Observation — every event appears as a card; its PreToolUse fires before its own permission check, so the agent keeps its own prompting"
},
{
name: "GitHub Copilot CLI",
wiring: "One hook file of ours in its hooks directory",
events: [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PostToolUseFailure",
"Stop",
"SubagentStart",
"SubagentStop",
"ErrorOccurred",
"PreCompact",
"Notification"
],
gating:
"Observation — every event appears as a card; its permission request runs before its own policy check, so the agent keeps its own prompting"
},
{
name: "Crush",
wiring:
"One entry in its JSONC config, the only event it documents",
events: ["PreToolUse"],
gating:
"Observation — tool calls appear as cards; its own permission prompts decide"
},
{
name: "Cursor",
wiring:
"Hook entries in its hooks.json, merged with your own. No permission hook, on purpose",
events: [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PostToolUse",
"PostToolUseFailure",
"Stop",
"SessionError",
"AfterAgentResponse",
"SubagentStop",
"PreCompact"
],
gating:
"Observation — a permission hook that stays silent blocks the action here, so Cursor keeps its own prompting"
},
{
name: "Amp",
wiring:
"In-process extension package. This Mac only — remote hosts skip it",
events: [
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
"SessionError"
],
gating:
"Observation — its plugin API has no permission-wait event, so the agent keeps its own prompting"
} }
]; ];
@@ -284,25 +371,27 @@ export default function NookAgentsPage() {
<> <>
<EdgeCacheHeaders maxAge={300} /> <EdgeCacheHeaders maxAge={300} />
<PageHead <PageHead
title="Agent coverage" title="Supported agents"
description="The coding agents The Nook connects to, with the events each hook reports and what you can do from a card: watch sessions live, answer permission prompts, unblock work." description="Every coding agent The Nook supports — all eighteen, with each hook's exact event coverage and what you can do from a card: watch sessions live, answer permission prompts, unblock work."
ogTitle="The coding agents The Nook connects to, and what each hook sees" ogTitle="The 18 agents The Nook supports, and what each hook sees"
/> />
<SubdomainHeader /> <SubdomainHeader />
<main class="min-h-screen px-[8vw] py-[8vh]"> <main class="min-h-screen px-[8vw] py-[8vh]">
<h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl"> <h1 class="text-text mb-3 text-3xl font-bold tracking-tight sm:text-4xl">
Agent coverage Supported agents
</h1> </h1>
<p class="text-subtext0 mb-2 max-w-2xl text-lg"> <p class="text-subtext0 mb-2 max-w-2xl text-lg">
The Nook watches coding agents in one place — your menu bar and notch The Nook watches eighteen coding agents in one place — your menu bar and
show sessions that are running, thinking, or blocked, and a card can notch show every session that is running, thinking, or blocked, and a
answer a permission prompt without leaving your keyboard. card answers permission prompts without leaving your keyboard.
</p> </p>
<p class="text-subtext1 mb-10 max-w-2xl text-sm"> <p class="text-subtext1 mb-10 max-w-2xl text-sm">
Each agent reports its own set of events. "Allow / Deny from a card" Each agent reports its own set of events. "Allow / Deny from a card"
means the agent hands the decision to The Nook while it waits; means the agent hands the decision to The Nook while it waits;
"Observe" means every event still appears live, but the agent keeps "Observe" means every event still appears live, but the agent keeps
its own prompting. its own prompting. Agents can run on this Mac or on remote machines
over SSH — remote sessions report into the same grid. Muse, Cline and
Amp run on this Mac only.
</p> </p>
<div class="grid gap-4 lg:grid-cols-2"> <div class="grid gap-4 lg:grid-cols-2">

View File

@@ -59,9 +59,9 @@ export default function NookLanding() {
<PageHead <PageHead
title="Agent control for your Mac" title="Agent control for your Mac"
ogTitle="The Nook — 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`} 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 /> <SubdomainHeader />
@@ -96,11 +96,11 @@ export default function NookLanding() {
The Nook The Nook
</div> </div>
<h1 class="text-text mb-4 text-4xl font-bold tracking-tight sm:text-5xl"> <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> </h1>
<p class="text-subtext0 mb-2 max-w-xl text-xl"> <p class="text-subtext0 mb-2 max-w-xl text-xl">
Follow your agents, respond to requests, and manage cooling, Agent orchestration, fan and thermal control in a beautiful native
charging, and audio from the top of your screen. UI.
</p> </p>
<p class="text-subtext1 mb-8 text-sm"> <p class="text-subtext1 mb-8 text-sm">
macOS 14.2+ · 14-day free trial · up to 3 devices macOS 14.2+ · 14-day free trial · up to 3 devices
@@ -145,7 +145,7 @@ export default function NookLanding() {
In action In action
</p> </p>
<h2 class="text-text mb-16 text-center text-3xl font-bold"> <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> </h2>
<FeatureBreakdowns /> <FeatureBreakdowns />
</div> </div>
@@ -164,9 +164,10 @@ export default function NookLanding() {
Try it free for 14 days Try it free for 14 days
</h2> </h2>
<p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed"> <p class="text-subtext0 mx-auto mb-10 max-w-2xl leading-relaxed">
The trial includes every module. After 14 days, a one-time Orchestrate your coding agents, take control of your fans and
purchase lets you keep using The Nook on up to three of your Macs. battery, give every app its own volume, capture your screen, and
There is no subscription. keep your day in the notch. When the trial ends, unlock everything
with a single one-time payment — no subscription, ever.
</p> </p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4"> <div class="flex flex-col items-center justify-center gap-4 sm:flex-row sm:space-x-4">
<Button <Button
@@ -186,7 +187,7 @@ export default function NookLanding() {
</a> </a>
</div> </div>
<p class="text-subtext1 mt-6 text-xs"> <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. your own Macs.
</p> </p>
</div> </div>
@@ -228,7 +229,8 @@ export default function NookLanding() {
</form> </form>
<Show when={resendSent()}> <Show when={resendSent()}>
<p class="text-subtext1 mt-4 text-center text-sm"> <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> </p>
</Show> </Show>
<Show when={resendError()}> <Show when={resendError()}>

View File

@@ -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);
});
});

View File

@@ -1,15 +1,35 @@
/** /**
* NOOK-1038 restores remote marketing for Tailscale and authorized known hosts, * Guard for the remote / SSH story on the Nook site, and for the two phrasings
* per Mike's correction. FRE-323's blanket removal is no longer the contract; * that must not come back with it (FRE-323 → FRE-997).
* 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. * FRE-323 removed the remote copy because it had been checked against claims
* Static source audit, like agents.test.ts; browser checks cover rendered layout. * the copy never made: the app does list hosts from `~/.ssh/config` and
* Tailscale, a one-time `ssh-copy-id` is a normal prerequisite the row explains,
* and the per-host credential is minted and copied automatically
* (`RemoteHostDiscovery`, `RemoteHostStatus.fixCommand`, `RemoteMirror`). Two
* things in the old copy were genuinely wrong, and this file guards those
* instead of the copy as a whole:
*
* - "Works across every hooked agent, local or remote" overstated it. Muse,
* Cline and Amp are `.unsupported` for remote in `AgentSpec.remote`, so the
* landing bullet and the /agents paragraph name them.
* - The count said thirteen while `AgentSpec.all` holds eighteen. The fix was
* to say eighteen, not to drop the count.
*
* So the removal guards from `d44133f` are gone: a removed sentence is exactly
* what this task restores. What stays is the pair of bans, because either one
* coming back would repeat the original defect.
*
* STATIC SOURCE AUDIT, the same pattern as `agents.test.ts` and
* `downloads.test.ts` (SolidJS does not render under `bun:test`).
*/ */
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
const ROOT = join(import.meta.dir, "../../.."); const ROOT = join(import.meta.dir, "../../..");
/** Shipped page and component source. */
const AUDITED = [ const AUDITED = [
"src/routes/nook/index.tsx", "src/routes/nook/index.tsx",
"src/routes/nook/agents.tsx", "src/routes/nook/agents.tsx",
@@ -19,93 +39,74 @@ const AUDITED = [
"src/components/nook/IslandExtras.tsx" "src/components/nook/IslandExtras.tsx"
]; ];
/** The overstatements the restored copy must never carry again. */
const BANNED_REMOTE_CLAIMS = [
// a universal claim: three registry agents cannot mirror over SSH
"every hooked agent, local or remote",
"Works across every hooked agent",
// the stale count this task replaced
"13 agents",
"all thirteen",
"thirteen coding agents"
];
/** User-facing copy only: comments are not copy and may name the old wording. */
function copyOf(file: string): string { function copyOf(file: string): string {
return readFileSync(join(ROOT, file), "utf8") return readFileSync(join(ROOT, file), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "") .replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, ""); .replace(/^\s*\/\/.*$/gm, "");
} }
describe("remote marketing reflects the supported setup (NOOK-1038)", () => { describe("the restored remote copy is on the site (FRE-997)", () => {
it("fills the collage's missing sixth grid slot with host-labelled remote sessions", () => { it("the landing collage sells agents on other machines", () => {
const collage = copyOf("src/components/nook/FeatureCollage.tsx"); const copy = 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"');
});
for (const file of AUDITED) {
it(`${file} makes no blanket authorization or agent-coverage promise`, () => {
const copy = copyOf(file).toLowerCase();
for (const phrase of [ for (const phrase of [
"Any SSH box", "Agents on other machines",
"Works across every hooked agent, local or remote", "one click installs the nook-hook",
"no SSH authorization needed" "Remote"
])
expect(copy).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(copy).toContain(phrase);
expect(text).toContain("NOOK-1038");
expect(text).toContain("Tailscale and authorized known hosts");
} }
expect(
readFileSync(join(ROOT, "marketing/drafts/remote-ssh/copy.md"), "utf8")
).toContain("Any SSH box, one click away");
});
}); });
describe("agent coverage count stays off the page (FRE-359 verdict B)", () => { it("the landing breakdown keeps the SSH section and its demo", () => {
for (const file of AUDITED) { const copy = copyOf("src/components/nook/FeatureBreakdowns.tsx");
it(`${file} states no coverage count`, () => {
const copy = copyOf(file);
for (const phrase of [ for (const phrase of [
"all thirteen", "Any SSH box, one click away",
"thirteen coding agents", "Pick a host from your ssh config or your tailnet and click once",
"13 agents", "Remote sessions are ordinary rows",
"every coding agent", "RemoteHostDemo"
"Supported agents" ]) {
]) expect(copy).toContain(phrase);
}
});
it("the chip is back", () => {
expect(copyOf("src/components/nook/IslandExtras.tsx")).toContain(
"One-click SSH setup"
);
});
it("/agents says remote sessions report into the same grid", () => {
const copy = copyOf("src/routes/nook/agents.tsx");
expect(copy).toContain("remote machines");
expect(copy).toContain("remote sessions report into the same grid");
});
});
describe("the two original overstatements stay off the site (FRE-323, FRE-359)", () => {
for (const file of AUDITED) {
it(`${file} claims no universal remote coverage and no stale count`, () => {
const copy = copyOf(file);
for (const phrase of BANNED_REMOTE_CLAIMS) {
expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase()); expect(copy.toLowerCase()).not.toContain(phrase.toLowerCase());
}
}); });
} }
it("/agents titles the table Agent coverage", () => { it("the landing names the agents that stay local", () => {
expect(copyOf("src/routes/nook/agents.tsx")).toContain("Agent coverage"); expect(copyOf("src/components/nook/FeatureBreakdowns.tsx")).toContain(
"Muse, Cline and Amp are this Mac only"
);
}); });
}); });

View File

@@ -14,10 +14,7 @@ import { describe, it, expect } from "bun:test";
import { readdirSync, existsSync, statSync } from "fs"; import { readdirSync, existsSync, statSync } from "fs";
function isRouteFile(name: string): boolean { function isRouteFile(name: string): boolean {
// Page routes are components; the `.ts` files beside them are pure content / return name.endsWith(".tsx") || name.endsWith(".ts");
// 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");
} }
function listRouteFiles(dir: string): string[] { 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 { function hasDispatchRoute(routeName: string, routesDir: string): boolean {
// Check for src/routes/<name>.tsx // Check for src/routes/<name>.tsx
if (existsSync(`${routesDir}/${routeName}.tsx`)) { if (existsSync(`${routesDir}/${routeName}.tsx`)) {
@@ -58,12 +41,14 @@ describe("subdomain routing dispatch invariant", () => {
const routesDir = "./src/routes"; const routesDir = "./src/routes";
it("every subdomain route has a top-level dispatch route", () => { it("every subdomain route has a top-level dispatch route", () => {
// List directory entries before filtering to route files. The previous const prefixDirs = listRouteFiles(routesDir)
// shape filtered with `listRouteFiles` first, which only ever returns .filter((name) => {
// `.tsx`/`.ts` names, so `statSync(...).isDirectory()` was never true try {
// and the assertion ran over an empty list — it passed while `/terms` return statSync(`${routesDir}/${name}`).isDirectory();
// had no dispatch file (FRE-1670). } catch {
const prefixDirs = listDirectories(routesDir) return false;
}
})
// Exclude non-product subdomains // Exclude non-product subdomains
.filter((name) => .filter((name) =>
["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name) ["nessa", "lineage", "gaze", "inputhalo", "nook"].includes(name)

View File

@@ -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>
);
}