Compare commits
2 Commits
fren-dev/F
...
641d3ed0ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 641d3ed0ed | |||
| b6b2bc8ef6 |
25
.env.example
@@ -55,8 +55,15 @@ TURSO_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
|
||||
TURSO_DB_API_TOKEN="<rotate-in-turso-dashboard>" # org-level API token
|
||||
TURSO_LINEAGE_URL="libsql://<lineage-db>.turso.io"
|
||||
TURSO_LINEAGE_TOKEN="<rotate-in-turso-dashboard>"
|
||||
NESSA_DB_URL="libsql://<nessa-db>.turso.io"
|
||||
NESSA_DB_TOKEN="<rotate-in-turso-dashboard>"
|
||||
# Clerk authentication — rotate via Clerk Dashboard or `clerk api .../rotate_secret_keys`
|
||||
NESSA_CLERK_SECRET="sk_test/live_<rotate-in-clerk-dashboard>" # secret key (sk_test_... for dev, sk_live_... for prod)
|
||||
NESSA_CLERK_JWT_ISSUER="https://<your-app>.clerk.accounts.dev" # JWT issuer from Clerk Dashboard
|
||||
NESSA_CLERK_WEBHOOK_SECRET="whsec_<rotate-in-clerk-dashboard>" # Svix signing secret from Clerk Dashboard → Webhooks → Signing Secret
|
||||
|
||||
APPLE_CLIENT_ID_LINEAGE=com...
|
||||
APPLE_CLIENT_ID_NESSA=com...
|
||||
|
||||
# ── Infra / integration tokens ──
|
||||
INFILL_BEARER_TOKEN="<rotate-at-infill-service>"
|
||||
@@ -68,21 +75,3 @@ GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / g
|
||||
# Source maps upload — create a token at: Settings > Projects > freno-dev > Client Keys (DSN) > Auth Token
|
||||
# or generate an internal auth token at: https://sentry.io/settings/account/api/keys/
|
||||
SENTRY_AUTH_TOKEN="sntrys_<generate-in-sentry-dashboard>"
|
||||
|
||||
# ── The Nook licensing ──
|
||||
NOOK_DB_URL="libsql://<nook-db>.turso.io"
|
||||
NOOK_DB_TOKEN="<rotate-in-turso-dashboard>" # eyJ...
|
||||
NOOK_LICENSE_PRIVATE_KEY="<generate-license-keypair>" # base64 PKCS8 Ed25519 private key (scripts/generate-license-keys.ts)
|
||||
NOOK_STRIPE_SK="sk_live_<rotate-in-stripe-dashboard>"
|
||||
NOOK_STRIPE_WEBHOOK_SECRET="whsec_<rotate-in-stripe-dashboard>"
|
||||
NOOK_STRIPE_PRICE_ID="price_<from-stripe-dashboard>"
|
||||
|
||||
# ── The Nook lifecycle emails ──
|
||||
# Daily cron (vercel.json → /api/the-nook/lifecycle) requires a Bearer secret.
|
||||
# Set CRON_SECRET and Vercel attaches it automatically to scheduled runs.
|
||||
# Generate: openssl rand -base64 64 | tr -d '\n'
|
||||
CRON_SECRET="<openssl-rand-base64>"
|
||||
# Optional override for the secret the endpoint accepts (falls back to CRON_SECRET).
|
||||
NOOK_LIFECYCLE_SECRET=""
|
||||
# Optional Brevo list id trial-start emails are added to (the lifecycle audience).
|
||||
NOOK_TRIAL_LIST_ID=""
|
||||
|
||||
4
.gitignore
vendored
@@ -31,7 +31,3 @@ perf-results-*.json
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# pygienium run-state and check artifacts
|
||||
.pygienium/
|
||||
scripts/
|
||||
.cache
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration.
|
||||
|
||||
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`) as the source-of-truth content components. The public browser path on each subdomain (e.g. `lineage.freno.me/privacy` → `/privacy`) is served by **host-aware root route dispatch**: the root route file (`src/routes/privacy.tsx`, `deletion.tsx`, `contact.tsx`, `downloads.tsx`, `index.tsx`) resolves on the public path on BOTH server and client, then selects the matching subdomain component via `useSite()`. Do **not** rewrite the request path server-side — SolidStart's client `Router` matches on `window.location.pathname`, so a server-only rewrite diverges SSR from hydration and causes hydration mismatches. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers — so the host-aware root dispatch is what actually serves subdomain pages.) **Every subdomain surface requires both files:** the `src/routes/<prefix>/<name>.tsx` content component AND a matching top-level dispatch file (`src/routes/<name>.tsx` or `src/routes/<name>/index.tsx`) that imports and dispatches to it — without the dispatch file, the page is unreachable on the subdomain. The `src/routes/subdomain-dispatch.test.ts` suite enforces this automatically on every `bun test` run.
|
||||
- **Route placement:** Subdomain pages live under `src/routes/<prefix>/*` (e.g. `src/routes/nessa/...`). The `vercel.json` host-based rewrites map each subdomain to its prefix.
|
||||
- **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host.
|
||||
- **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters).
|
||||
- **Auth:** Host-scoped only — no cookie domain broadening.
|
||||
|
||||
@@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin";
|
||||
|
||||
export default defineConfig({
|
||||
middleware: "./src/middleware.ts",
|
||||
vite: {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
@@ -10,7 +11,21 @@ export default defineConfig({
|
||||
org: "mikefreno",
|
||||
project: "freno-dev",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
telemetry: false
|
||||
telemetry: false,
|
||||
sourcemaps: {
|
||||
assets: [
|
||||
{
|
||||
type: "bundle",
|
||||
path: "dist/client/assets/",
|
||||
urlPrefix: "~/assets/"
|
||||
},
|
||||
{
|
||||
type: "sourcemap",
|
||||
path: "dist/client/assets/",
|
||||
urlPrefix: "~/assets/"
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
],
|
||||
build: {
|
||||
@@ -38,6 +53,6 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
preset: "vercel"
|
||||
preset: "node-server"
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# The deploy line (FRE-1695)
|
||||
|
||||
Written 2026-11-09 by the CTO. Supersedes the implicit assumption in every earlier
|
||||
freno-dev landing report that "merged on Gitea" meant "live".
|
||||
|
||||
## The defect
|
||||
|
||||
Production `freno.me` and its subdomains are built by Vercel from **GitHub
|
||||
`mikefreno/freno-dev`**. Agents merge into **Gitea `Mike/freno-dev`**. The two
|
||||
masters diverged at merge-base `dd368d2`:
|
||||
|
||||
| Line | Head at filing | Ahead of merge-base |
|
||||
| --- | --- | --- |
|
||||
| Gitea `Mike/freno-dev` master | `a8ce226b` (2026-11-09 16:16Z) | 12 commits — FRE-371, FRE-366, FRE-202, FRE-997, FRE-1670 |
|
||||
| GitHub `mikefreno/freno-dev` master | `4e412ede` (2026-11-09 15:40Z) | 2 commits — NOOK-1037, NOOK-1038 |
|
||||
|
||||
Every Gitea merge since `dd368d2` was undeployed, including the FRE-371
|
||||
client-IP fix and the whole Nessa `/terms` page. Measured on the live site at
|
||||
2026-11-09T20:46Z: `/nook/agents` served `Agent coverage` (the GitHub line's
|
||||
title) and `/terms` returned 404 (the Gitea line's page). No agent run can push
|
||||
to GitHub — `/paperclip/.git-credentials` holds `git.freno.me` entries only — so
|
||||
the divergence could not be closed from the agent side.
|
||||
|
||||
## Decision
|
||||
|
||||
**Gitea `Mike/freno-dev` is the canonical repository. GitHub
|
||||
`mikefreno/freno-dev` is a mirror, and it is the tree Vercel builds.**
|
||||
|
||||
One line, one direction: Gitea master → GitHub master → Vercel → live.
|
||||
|
||||
### Why this shape and not the alternatives
|
||||
|
||||
- **Vercel cannot build from Gitea.** Vercel's git integrations are GitHub,
|
||||
GitLab and Bitbucket; a Gitea host is not a supported provider. So "point
|
||||
Vercel at the repo agents push to" is not a setting that exists. Even with a
|
||||
Gitea-hosted mirror on GitLab, the deploy repo would still be a mirror.
|
||||
- **Agents can only push to Gitea.** The per-agent credential is Gitea-only
|
||||
(`git.freno.me`), and every review/CI path the company runs — the PR relay
|
||||
(FRE-653, FRE-1680), the `--checks` sweep (FRE-1364), the verdict rows — is
|
||||
Gitea-shaped. Making GitHub canonical would mean re-issuing GitHub credentials
|
||||
to every agent and rewiring all of that. Keeping Gitea canonical keeps the
|
||||
authoring side exactly as it is.
|
||||
- **Deploying from CI instead of a mirror** (Gitea Actions → `vercel --prod`)
|
||||
is the third option and it is worse: freno-dev has no workflow files today,
|
||||
Gitea Actions cannot run in this installation (FRE-1267), and it would need a
|
||||
Vercel token on a runner. A mirror needs one credential, in one place, and no
|
||||
new CI.
|
||||
|
||||
### Reconcile first, then mirror
|
||||
|
||||
The mirror must fast-forward. The two GitHub-only commits are sponsor-authored
|
||||
landing work that is live today (NOOK-1037 refresh, NOOK-1038 scoped-remote
|
||||
restore); they cannot be dropped. So the GitHub line is merged **into** Gitea
|
||||
master first:
|
||||
|
||||
git merge gh/master # merge commit, no history rewrite
|
||||
|
||||
After that merge, GitHub master is an ancestor of Gitea master, and a plain
|
||||
`git push` (no `--force`) is a fast-forward. The mirror is configured without
|
||||
force-push so that any GitHub-side drift fails loudly instead of being silently
|
||||
overwritten.
|
||||
|
||||
Two conflicts were resolved in the merge, both copy contracts:
|
||||
|
||||
1. `src/components/nook/FeatureBreakdowns.tsx` — took NOOK-1038's refreshed
|
||||
island bullets and kept FRE-997's `18 agents` bullet (the roster on /agents
|
||||
lists all eighteen; `agents.test.ts` asserts equality).
|
||||
2. `src/routes/nook/remote-claims.test.ts` — one file holding both contracts.
|
||||
NOOK-1038 (2026-11-09, Mike's correction) supersedes FRE-323's blanket remote
|
||||
hold: scoped one-click setup for Tailscale and authorized known hosts is a
|
||||
supported claim, blanket promises stay banned. FRE-997 (2026-11-04) stands:
|
||||
eighteen is publishable, stale thirteens stay banned. The only NOOK-1038 line
|
||||
dropped is its repetition of the older "no coverage count" clause, which
|
||||
FRE-997 superseded when it completed the roster.
|
||||
|
||||
## How a merge becomes a deploy
|
||||
|
||||
1. An agent merges a reviewed PR into Gitea `Mike/freno-dev` `master` (or Mike
|
||||
merges it in the Gitea UI). Nothing else changes for the engineer.
|
||||
2. A **Gitea push mirror** on `Mike/freno-dev` pushes `master` to
|
||||
`https://github.com/mikefreno/freno-dev.git` with sync-on-commit enabled.
|
||||
3. Vercel sees the GitHub push and builds production from GitHub `master`.
|
||||
4. `scripts/verify-deploy.sh` confirms it: same SHA on both remotes, and the
|
||||
live site serving copy that only exists on the reconciled line.
|
||||
|
||||
**Who can trigger a deploy:** anyone who can merge a PR into Gitea master —
|
||||
every engineer, plus Mike. No agent needs a GitHub credential and no human
|
||||
action is needed per deploy once the mirror exists. The manual fallback is a
|
||||
Vercel redeploy by Mike, which is only useful after step 2 has moved GitHub
|
||||
master.
|
||||
|
||||
**Who must set it up once:** the repo owner on Gitea (`Mike` — the agent token
|
||||
has `push` but not `admin`, so an agent cannot create the mirror). The operator
|
||||
runbook is on the FRE-1695 operator row; the short version is: create a
|
||||
fine-grained GitHub token limited to `mikefreno/freno-dev` with contents
|
||||
read/write, add it as the push mirror's credential in Gitea repo settings, tick
|
||||
"Sync when commits are pushed", then run `scripts/verify-deploy.sh`.
|
||||
|
||||
Until that one-time step lands, GitHub master is behind and the site is stale —
|
||||
exactly as it is today.
|
||||
|
||||
## Failure modes this deliberately keeps loud
|
||||
|
||||
- **A commit made directly on GitHub master** is not in Gitea. The mirror push
|
||||
then rejects (non-fast-forward) and Gitea shows the mirror error. Do not commit
|
||||
to GitHub `master`; land it in Gitea and let it mirror.
|
||||
- **A merge that is only a Gitea merge** is not live until the mirror runs. Check
|
||||
`scripts/verify-deploy.sh` before calling a site change published.
|
||||
- **The first mirrored deploy switches on a cron that deletes production
|
||||
rows.** `vercel.json` carries two crons. `/api/the-nook/lifecycle`
|
||||
(13:00 UTC) is already on the tree Vercel builds today, so it runs.
|
||||
`/api/analytics-retention` (12:00 UTC, added in `1d9287a`, FRE-369) is
|
||||
only on Gitea master: the first deploy that follows the mirror starts
|
||||
calling it, and the next 12:00 UTC runs
|
||||
`DELETE FROM VisitorAnalytics WHERE created_at < datetime('now','-365 days')`
|
||||
against the production database. Not staged, not reversible — whatever is
|
||||
older than the window
|
||||
is gone the first time it fires. The window is
|
||||
`ANALYTICS_RETENTION_DAYS`, and unset, non-positive, or over 730 each
|
||||
resolve to the 365-day default (`src/server/analytics-retention.ts`).
|
||||
365 is the conservative end — picked so that switching the schedule on is
|
||||
not itself a data-loss event — but conservative still means *over a year
|
||||
old gets deleted*. Set the window in Vercel **before** the first mirrored
|
||||
deploy if 365 is not what you want. The cron call is authorized with
|
||||
`Authorization: Bearer <ANALYTICS_RETENTION_SECRET ?? CRON_SECRET>`
|
||||
(`src/routes/api/analytics-retention.ts`), and the endpoint fails closed:
|
||||
with no secret configured every call is a 401, so a missing secret is a
|
||||
table that quietly stops being pruned, never an unauthenticated bulk
|
||||
delete. Vercel supplies `CRON_SECRET` to its own crons and production
|
||||
already runs the 13:00 job, so assume the secret is there and it runs.
|
||||
- **The FRE-371 fix's actual exploitability in production** depends on how the
|
||||
edge sets `x-forwarded-for`/`cf-connecting-ip`. That is measured and gated by
|
||||
the Security Engineer, not assumed here.
|
||||
|
||||
## Verifying
|
||||
|
||||
scripts/verify-deploy.sh
|
||||
|
||||
It is read-only and needs no credentials — that is what makes it safe to run
|
||||
from an agent run as well as by the operator, and it is why the checks stay
|
||||
read-only. It checks both remotes' master SHA, that `https://freno.me/`
|
||||
answers a direct 200, the live Nook/Nessa copy, and (when the commit is
|
||||
present locally) that `2fd2a4a` is an ancestor of the deployed commit.
|
||||
|
||||
The root probe is there because every other check can stay green while the
|
||||
root deploy is broken. It is status-only, and it deliberately does not probe
|
||||
`https://freno.me/terms`: `SITEMAP_ROUTES.main` in
|
||||
`src/lib/sitemap-routes.ts` has no `/terms` — Terms of Use is a Nessa
|
||||
surface (`nessa.freno.me/terms`), so a 404 on the root domain's `/terms` is
|
||||
the design, not a defect.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Marketing tree
|
||||
|
||||
One voice, one place. Every later artifact draws on `positioning.md` — do not
|
||||
invent framing per-asset.
|
||||
|
||||
```
|
||||
positioning.md # source of truth: wedge, voice, banned phrasings, approved claims
|
||||
calendar.json # scheduled pushes: date, channel, status
|
||||
briefs/<channel>.md # per-channel rules, format, limits, what "good" looks like
|
||||
drafts/<id>/ # agent output: copy.md, assets/, rationale.md
|
||||
pending/ approved/ posted/ # human gate by directory move
|
||||
metrics/weekly.md # funnel snapshot, generated
|
||||
listening.md # buying-intent threads + drafted replies
|
||||
```
|
||||
|
||||
## Hard rule
|
||||
|
||||
**`approved/` is the only thing a human ever publishes from. Nothing posts itself.**
|
||||
|
||||
Workflow: agent drafts into `drafts/<id>/` → moves to `pending/` when complete →
|
||||
a human reviews and moves it to `approved/` → the human publishes from `approved/`
|
||||
under their own account → the artifact (or a pointer) lands in `posted/`.
|
||||
|
||||
Nothing in `drafts/` or `pending/` is ever publishable, by anyone, by any means.
|
||||
|
||||
## The weekly read
|
||||
|
||||
`bun --env-file=.env scripts/weekly-metrics.ts` (or `bun run metrics:weekly`)
|
||||
rewrites `metrics/weekly.md` from the live databases: the staged funnel, the
|
||||
campaign breakdown (`getAnalyticsSummary.topCampaigns`' grouping), and the one
|
||||
stage that moved. It needs `TURSO_DB_URL`, `TURSO_DB_TOKEN`, `NOOK_DB_URL` and
|
||||
`NOOK_DB_TOKEN` — no other production secret. The file is generated; never
|
||||
hand-edit it. The numbers are engineering's; the interpretation is the CMO's.
|
||||
@@ -1 +0,0 @@
|
||||
# approved
|
||||
@@ -1 +0,0 @@
|
||||
# briefs
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"$comment": "Scheduled pushes: date, channel, status. Statuses: draft | pending | approved | posted. Move nothing to posted without the artifact living in approved/ first — see positioning.md rule 5.",
|
||||
"pushes": []
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# drafts
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,15 +0,0 @@
|
||||
# Listening — buying-intent threads
|
||||
|
||||
Agent-found threads where someone is describing a problem The Nook solves, with a
|
||||
drafted helpful-first reply. Replies are drafted here, reviewed, and posted **by a
|
||||
human** with a single disclosed mention — helpful first, product second.
|
||||
|
||||
Rules:
|
||||
|
||||
- Real threads only — no invented scenarios.
|
||||
- The drafted reply must stand alone as a helpful answer if the mention is removed.
|
||||
- One disclosed mention per reply, maximum.
|
||||
- Never post from this file; replies move through `approved/`.
|
||||
|
||||
| Date | Source | Thread | Problem signal | Drafted reply |
|
||||
|---|---|---|---|---|
|
||||
@@ -1,43 +0,0 @@
|
||||
# Weekly funnel snapshot
|
||||
|
||||
Window: **2026-10-22 18:05:00 → 2026-10-29 18:05:00 UTC** (7 days). Previous: 2026-10-15 18:05:00 → 2026-10-22 18:05:00 UTC.
|
||||
|
||||
Generated by `bun --env-file=.env scripts/weekly-metrics.ts`; regenerated weekly, never hand-edited. The analysis is the CMO's.
|
||||
|
||||
## Funnel
|
||||
|
||||
| Stage | This week | Last week | Change |
|
||||
|---|---:|---:|---:|
|
||||
| visit (nook.freno.me page views) | 0 | 0 | 0 |
|
||||
| download click | 0 | 0 | 0 |
|
||||
| DMG served | 0 | 0 | 0 |
|
||||
| trial started | 2 | 0 | +2 |
|
||||
| checkout started (Stripe session) | 0 | 0 | 0 |
|
||||
| purchase (licence issued) | 0 | 0 | 0 |
|
||||
| activation | 0 | 0 | 0 |
|
||||
|
||||
## Campaigns
|
||||
|
||||
Top 10 by volume, untagged excluded — the same grouping `getAnalyticsSummary.topCampaigns` returns.
|
||||
|
||||
| Source | Medium | Campaign | Rows |
|
||||
|---|---|---|---:|
|
||||
| x | — | — | 1 |
|
||||
|
||||
1 of 291 rows in the window carry campaign tags; the rest are untagged and therefore unattributed.
|
||||
|
||||
## The one thing that moved
|
||||
|
||||
**trial started: 0 → 2 (+2).**
|
||||
|
||||
## Before acting on these numbers
|
||||
|
||||
- **291 of 291 rows in the window predate the FRE-366 instrumentation.** They carry no host, and their recorded address is the production proxy's loopback (127.0.0.1 / ::1) — the writers then recorded the direct peer, not the visitor. They are excluded from every host-scoped stage above (visits, and the download click/served split), and "unique visitors" over them still collapses to one host. Rows recorded since carry the site they addressed and the visitor's address truncated to a prefix (/24, or /56 for IPv6 — the FRE-369 privacy floor), so "unique visitors" counts networks, not machines.
|
||||
- **Download rows are automation-heavy.** Of this window's 137 download rows, 0 are clicks and 0 are served files; the remaining 137 predate the click/served stage label and cannot be split, so they count in neither stage. 112 of all 137 are not browser user agents (curl, Sparkle, link-preview crawlers).
|
||||
- **No conversion rate before the cohort matures.** Trial→paid belongs to the mature Day 28–35 read (FRE-197); a ratio from a partial cohort is not a metric.
|
||||
|
||||
## Sources
|
||||
|
||||
- `VisitorAnalytics` (freno.me Turso DB) — visits, downloads, campaigns.
|
||||
- `trials`, `licenses`, `activations` (the-nook Turso DB) — the paid funnel.
|
||||
- Generated 2026-10-29T18:05:00Z.
|
||||
@@ -1 +0,0 @@
|
||||
# pending
|
||||
@@ -1,158 +0,0 @@
|
||||
# The Nook — Positioning (source of truth)
|
||||
|
||||
Every drafted asset draws its voice, claims, and constraints from this file. If an
|
||||
artifact invents its own framing, fix the artifact or fix this file — never both
|
||||
silently diverging.
|
||||
|
||||
**Reconciled 2026-10-29**, and again **2026-11-03** for the FRE-997 count
|
||||
correction, against the CTO's verdicts ([FRE-315], [FRE-322], [FRE-354],
|
||||
[FRE-997]), the Chief of Staff's price decision (2026-10-29 04:01), the Plan B
|
||||
launch route ([FRE-346] → [FRE-203] `plan-b`), and `The-Nook` master (app floor is
|
||||
**14.2**, not 14.0 — `freno-dev` `881190d`, site merge `134f41e`). The previous
|
||||
version of this file named an upstream `the-nook/docs/marketing-strategy.md` that
|
||||
no longer exists; the verdicts below are the source of truth it was pointing at.
|
||||
|
||||
[FRE-315]: https://paperclip.freno.me/FRE/issues/FRE-315
|
||||
[FRE-322]: https://paperclip.freno.me/FRE/issues/FRE-322
|
||||
[FRE-354]: https://paperclip.freno.me/FRE/issues/FRE-354
|
||||
[FRE-320]: https://paperclip.freno.me/FRE/issues/FRE-320
|
||||
[FRE-346]: https://paperclip.freno.me/FRE/issues/FRE-346
|
||||
[FRE-203]: https://paperclip.freno.me/FRE/issues/FRE-203
|
||||
[FRE-997]: https://paperclip.freno.me/FRE/issues/FRE-997
|
||||
|
||||
## The sentence
|
||||
|
||||
There is no single approved public sentence yet, and saying otherwise is the one
|
||||
mistake this file exists to prevent. What exists is a **launch claim set** — two
|
||||
sentences, both from one 15-second take, recorded as the chosen route by the Chief
|
||||
of Staff on 2026-10-29 ([FRE-346] `decision` §2–§4):
|
||||
|
||||
| Slot | Exact sentence | Status today |
|
||||
|---|---|---|
|
||||
| Launch line | **"Every agent you're running reports into one notch, and the one waiting on you pins above the rest."** | Verified by code ([FRE-322] §3). Needs the one-Mac take before it is publishable. |
|
||||
| Second line, same take | **"Approve an agent's tool call from your Mac's notch, and the agent carries on."** | Needs the demo ([FRE-322] §1). The same take settles it. |
|
||||
|
||||
Working line for internal writing, not for publication:
|
||||
|
||||
> **Your Mac and your agents, under control.** One notch for the whole fleet: you
|
||||
> see what is running, you unblock what is waiting, and the machine stays cool
|
||||
> while it works.
|
||||
|
||||
## The wedge
|
||||
|
||||
The thing to say over and over, in every channel, in slightly different words:
|
||||
|
||||
> **An agent blocked on a permission prompt is the most expensive kind of idle.**
|
||||
> You pay for the model, you pay for the wait, and you get neither.
|
||||
|
||||
It is acute, quantifiable, and true — and it is the demo: a card appears, you tap
|
||||
Allow, work resumes. Ten seconds, no explanation needed.
|
||||
|
||||
## The consolidation reframe
|
||||
|
||||
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
||||
app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||
|
||||
> One app instead of a notch toy, a fan utility, a battery limiter, a menu-bar stats
|
||||
> strip, and a notification hack. One purchase, no subscription.
|
||||
|
||||
**No price, no discount and no future price in copy.** The price is $10, it stays
|
||||
$10, and the dated rise to $15 is withdrawn (Chief of Staff, 2026-10-29 04:01). A
|
||||
price change is a Chief of Staff + sponsor call; it is never a marketing decision
|
||||
and never a launch asset.
|
||||
|
||||
## Hard rules
|
||||
|
||||
### 1. Category nouns, never product nouns
|
||||
|
||||
`freno-dev/AGENTS.md` forbids naming any competitor in user-facing copy — landing
|
||||
pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all of it.
|
||||
|
||||
- Use **category nouns**: "notch app", "menu-bar monitor", "fan control utility",
|
||||
"battery charge limiter", "agent notification hack". These carry the same search
|
||||
intent and are fully compliant.
|
||||
- Compare against **the status quo, not a competitor**: "vs. a terminal bell you
|
||||
don't hear", "vs. four utilities in your menu bar", "vs. finding out an hour later."
|
||||
- The rule does not reach App Store–external review replies, direct email, and
|
||||
community replies written by a human, where quoting what a user said about another
|
||||
tool is normal conversation.
|
||||
|
||||
### 2. A claim needs a verdict, not just a test
|
||||
|
||||
Copy that says "every agent", "13 agents", "no data leaves the device" must point at
|
||||
both a test **and** a CTO verdict naming the exact sentence. A passing test is not a
|
||||
verdict; a verdict is not a recording. If a sentence cannot be traced to a row in the
|
||||
tables below, it does not go in copy.
|
||||
|
||||
### 3. Honest privacy scoping
|
||||
|
||||
Telemetry and the licence check **do** leave the device: Sentry runs on **every
|
||||
launch, unconditionally** (with the Mac's IP address via `sendDefaultPii`), and the
|
||||
trial check sends `IOPlatformUUID` and the device name to `freno.me` ([FRE-322]
|
||||
§2.4, corrected by [FRE-354] `(b)`). Never publish, in any wording:
|
||||
|
||||
- "no data leaves the device" / "local-only" / "no telemetry" / "no account"
|
||||
- "nothing leaves your Mac"
|
||||
|
||||
The one favourable engine sentence — about the **hook and bridge only**, never
|
||||
"The Nook" — as corrected by [FRE-354] `(j)`: the hook and bridge **are not a cloud
|
||||
service**. Do not write "talk to nothing but your Mac" (too absolute: the
|
||||
`NOOK_PORTS` fan-out resolves `host.docker.internal` and the default gateway, and
|
||||
`defaultGateway()` opens a no-packet UDP socket to `8.8.8.8:80`), and do not narrow
|
||||
it to "over SSH" (a tailnet peer dials directly).
|
||||
|
||||
It is verified as a code fact ([FRE-322] §2, [FRE-354] `(j)`), and it is still
|
||||
**held** until the disclosures land: Sentry gated behind a preference or removed,
|
||||
and `/privacy` + `/faq` corrected against the egress inventory (patch on branch
|
||||
`faq/from-objections-2026-10-29`, verified by [FRE-354]). A true sentence next to
|
||||
false ones is worse than no sentence.
|
||||
|
||||
### 4. Sell what works today
|
||||
|
||||
An iPhone + Apple Watch companion **is** in users' hands as a TestFlight beta
|
||||
(`ios-v0.3.2`, 2026-10-22; [FRE-354] open question). That makes one thing true and
|
||||
one thing forbidden: say "TestFlight beta" when the phone exists in a sentence at
|
||||
all, and never claim approvals-from-phone as a shipped capability without its own
|
||||
CTO verdict — no "(coming soon)" parentheticals either way.
|
||||
|
||||
[gpt-6.1-sol] **NOOK-1038:** Mike approved restoring remote-agent marketing.
|
||||
One-click setup works for **Tailscale and authorized known hosts**. Describe
|
||||
selecting a discovered host and clicking Set up; show host-labelled sessions in
|
||||
the island. `RemoteHostsController.setUp` runs installation and a return-path
|
||||
probe. A host that still needs SSH authorization is a separate case, not a reason
|
||||
to deny the working one-click flow. `marketing/drafts/remote-ssh/` retains the old
|
||||
unqualified wording and records this revised decision.
|
||||
|
||||
### 5. Humans hold every account
|
||||
|
||||
No agent authenticates, posts, upvotes, or DMs. No sock puppets, no self-mention
|
||||
without disclosure. `approved/` **is the only thing a human ever publishes from** —
|
||||
nothing posts itself.
|
||||
|
||||
## Claims: status at 2026-11-03
|
||||
|
||||
Each row names the evidence, not a feeling. Re-read the verdict before using a row.
|
||||
|
||||
| Claim | Status | Evidence / next step |
|
||||
|---|---|---|
|
||||
| Approve an agent's tool call from the notch, and it carries on | **Needs demo** — not publishable | [FRE-322] §1. `AgentLiveTurnTests` has never run on a signed-in host; one observed local run settles it. Say "an agent", never "any agent", never "always". |
|
||||
| Every agent you're running reports into one notch; the blocked one pins above the rest | **Verified by code, gated on the take** | [FRE-322] §3. Same one-Mac take as above. No count in the sentence. |
|
||||
| The hook and bridge are not a cloud service | **Verified code fact, held on disclosure** | [FRE-322] §2, wording corrected by [FRE-354] `(j)`. Held until Sentry is gated/removed and `/privacy` + `/faq` land. Never widen to "The Nook", never "nothing but your Mac". |
|
||||
| Fan, thermal and charge control | **No claim verdict yet — do not lead with it** | Backed by `helper/` (privileged helper, SMC, no kext), but no CTO sentence exists for it. Documentary, not launch. |
|
||||
| One-time purchase, no subscription | **Publishable fact, price not a launch asset** | $10, held at $10. No dated rise, no discount framing, no pricing copy. |
|
||||
| Agent coverage numbers ("eighteen agents", "18 agents") | **Publishable at 18** | [FRE-997]: the /agents table now lists every row of `AgentSpec.all` and `agents.test.ts` asserts equality with the 18-name registry in registry order, so the landing bullet, the marquee chip and the /agents copy say 18. No other number is publishable. |
|
||||
| macOS 14.2 or later | **Publishable fact** | The app cannot start below 14.2 (`freno-dev` `881190d`, site `134f41e`). "macOS 14+" is false and was fixed on the site. |
|
||||
| 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. |
|
||||
| "Nothing leaves your Mac", "no telemetry", "local-only", "no account" | **False today — banned** | Sentry unconditional, licence check with hardware fingerprint ([FRE-322] §2.4). |
|
||||
| Approvals from the iPhone or Apple Watch | **No verdict — not a launch claim** | The companion is a TestFlight beta ([FRE-354]); the approval-from-phone claim has no CTO verdict and stays out of launch copy. |
|
||||
|
||||
## Voice
|
||||
|
||||
- Short, concrete, no hype adjectives. Numbers over adjectives — the only count
|
||||
that ships is eighteen, and it is the registry's own size (see the coverage row
|
||||
above).
|
||||
- The notch is the product's home; agents are the product's point.
|
||||
- Never apologize for being a utility. The utility is the point.
|
||||
- Banned phrasings: competitor names (see rule 1), "AI-powered", "revolutionary",
|
||||
"game-changer", "seamlessly", any unverifiable superlative — and the specific
|
||||
banned sentences in rule 3 and the table above.
|
||||
@@ -1 +0,0 @@
|
||||
# posted
|
||||
23
package.json
@@ -1,24 +1,25 @@
|
||||
{
|
||||
"name": "freno.(dev/me)",
|
||||
"name": "example-with-trpc",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vinxi dev",
|
||||
"dev-flush": "vinxi dev --env-file=.env",
|
||||
"build": "vinxi build",
|
||||
"vercel-build": "rm -rf .vinxi .output node_modules/.vite && vinxi build",
|
||||
"start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vinxi start",
|
||||
"test": "bun test",
|
||||
"test:security": "bun test src/server/security/",
|
||||
"test:watch": "bun test --watch",
|
||||
"test:coverage": "bun test --coverage",
|
||||
"perf": "bun run scripts/perf-test.ts",
|
||||
"perf:compare": "bun run scripts/perf-compare.ts",
|
||||
"metrics:weekly": "bun --env-file=.env scripts/weekly-metrics.ts",
|
||||
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
||||
"perf:compare": "bun run scripts/perf-compare.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.953.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
||||
"@clerk/backend": "^3.12.0",
|
||||
"@libsql/client": "^0.15.15",
|
||||
"@motionone/solid": "^10.16.4",
|
||||
"@sentry/solidstart": "^10.67.0",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
@@ -26,11 +27,13 @@
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"@tiptap/core": "^3.14.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.14.0",
|
||||
"@tiptap/extension-color": "^3.14.0",
|
||||
"@tiptap/extension-details": "^3.14.0",
|
||||
"@tiptap/extension-details-content": "^2.26.2",
|
||||
"@tiptap/extension-details-summary": "^2.26.2",
|
||||
"@tiptap/extension-image": "^3.14.0",
|
||||
"@tiptap/extension-link": "^3.14.0",
|
||||
"@tiptap/extension-list-item": "^3.14.0",
|
||||
"@tiptap/extension-subscript": "^3.14.0",
|
||||
"@tiptap/extension-superscript": "^3.14.0",
|
||||
"@tiptap/extension-table": "^3.14.0",
|
||||
@@ -40,11 +43,13 @@
|
||||
"@tiptap/extension-task-item": "^3.14.0",
|
||||
"@tiptap/extension-task-list": "^3.14.0",
|
||||
"@tiptap/extension-text-align": "^3.14.0",
|
||||
"@tiptap/extension-text-style": "^3.14.0",
|
||||
"@tiptap/pm": "^3.14.0",
|
||||
"@tiptap/starter-kit": "^3.14.0",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"@tursodatabase/api": "^1.9.2",
|
||||
"@typeschema/valibot": "^0.13.4",
|
||||
"bcrypt": "^6.0.0",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"fast-diff": "^1.3.0",
|
||||
@@ -55,6 +60,7 @@
|
||||
"motion": "^12.23.26",
|
||||
"solid-js": "^1.9.5",
|
||||
"solid-tiptap": "^0.8.0",
|
||||
"svix": "^1.98.0",
|
||||
"ua-parser-js": "^2.0.7",
|
||||
"uuid": "^13.0.0",
|
||||
"vinxi": "^0.5.7",
|
||||
@@ -64,11 +70,18 @@
|
||||
"node": "24.x"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@sentry/vite-plugin": "^5.4.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/fast-diff": "^1.2.2",
|
||||
"chrome-launcher": "^1.2.1",
|
||||
"lighthouse": "^13.0.1",
|
||||
"playwright": "^1.57.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2"
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"trpc-panel": "^1.3.4",
|
||||
"vite-bundle-visualizer": "^1.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 5.9 MiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "Gaze",
|
||||
"short_name": "Gaze",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/gaze/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/gaze/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#002cff",
|
||||
"background_color": "#002cff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.0 MiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "InputHalo",
|
||||
"short_name": "InputHalo",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/inputhalo/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/inputhalo/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#41a5ff",
|
||||
"background_color": "#41a5ff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 860 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "Life and Lineage",
|
||||
"short_name": "Lineage",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/lineage/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/lineage/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#a13536",
|
||||
"background_color": "#a13536",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 64 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "Nessa",
|
||||
"short_name": "Nessa",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/nessa/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/nessa/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#527640",
|
||||
"background_color": "#527640",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
@@ -1,4 +0,0 @@
|
||||
<!-- Official Claude mark — Anthropic favicon, https://claude.ai/favicon.svg -->
|
||||
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,4 +0,0 @@
|
||||
<!-- Codex mark — official OpenAI Codex X (canonical path used by Cloudflare docs / lobe-icons / shadcn logos, all sourced from the OpenAI brand), white colorway for dark surfaces -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFFFFF" fill-rule="evenodd">
|
||||
<path d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "The Nook",
|
||||
"short_name": "The Nook",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/nook/favicon/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/nook/favicon/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#4C9FBC",
|
||||
"background_color": "#4C9FBC",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 52 KiB |
@@ -1,21 +1,9 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /blog
|
||||
|
||||
# Private / dynamic paths — not for indexing.
|
||||
# Keeps crawlers off auth, admin, and API pages so they don't burn
|
||||
# uncached SSR function invocations.
|
||||
Disallow: /account
|
||||
Disallow: /analytics
|
||||
Disallow: /api/
|
||||
Disallow: /blog/create
|
||||
Disallow: /blog/edit
|
||||
Disallow: /checkout
|
||||
Disallow: /databaseMGMT
|
||||
Disallow: /debug/
|
||||
Disallow: /error-test
|
||||
Allow: /projects
|
||||
Disallow: /login
|
||||
Disallow: /success
|
||||
Disallow: /test
|
||||
Disallow: /debug/
|
||||
Disallow: /databaseMGMT
|
||||
|
||||
Sitemap: https://www.freno.me/sitemap.xml
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ConnectionFactory } from "~/server/db-connections";
|
||||
import { backfillDownloads } from "~/server/backfill-downloads";
|
||||
|
||||
// One-off: derive canonical /api/downloads/<asset> rows from download history
|
||||
// recorded before `trackDownload` existed. Idempotent — re-running adds
|
||||
// nothing, because derived rows carry a `backfill-<source id>` primary key.
|
||||
//
|
||||
// bun --env-file=.env scripts/backfill-downloads.ts
|
||||
|
||||
const result = await backfillDownloads(ConnectionFactory());
|
||||
|
||||
if (result.length === 0) {
|
||||
console.log("Nothing to backfill: no legacy download rows left to derive.");
|
||||
} else {
|
||||
for (const { asset, added } of result) {
|
||||
console.log(`${asset}: +${added}`);
|
||||
}
|
||||
console.log(
|
||||
`Total: +${result.reduce((sum, row) => sum + row.added, 0)} derived download rows.`
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* One-shot The Nook license Ed25519 keypair generator.
|
||||
*
|
||||
* Run with: bun scripts/generate-license-keys.ts
|
||||
*
|
||||
* Prints:
|
||||
* - The Ed25519 PRIVATE key as base64 PKCS8 DER → NOOK_LICENSE_PRIVATE_KEY
|
||||
* (freno-dev env).
|
||||
* - The raw 32-byte Ed25519 public key (the X coordinate) as base64 SPKI DER
|
||||
* suffix → compiled into the Swift `LicenseVerifier.PublicKeyConstant`.
|
||||
*
|
||||
* The private key lives ONLY in env — never in git.
|
||||
* Paste the public key into Sources/NookCore/Licensing/LicenseVerifier.swift
|
||||
* (step 4 of the distribution plan) after running this once.
|
||||
*/
|
||||
import { generateKeyPairSync, createPrivateKey } from "node:crypto";
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||
|
||||
const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" });
|
||||
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" });
|
||||
|
||||
// Raw 32-byte X coordinate: tail of the SPKI DER public key.
|
||||
const publicKeySpki = publicKey.export({ format: "der", type: "spki" });
|
||||
const rawPublic = publicKeySpki.subarray(-32);
|
||||
|
||||
console.log("── The Nook license keypair ──────────────────────────────");
|
||||
console.log("NOOK_LICENSE_PRIVATE_KEY (base64 PKCS8 DER):");
|
||||
console.log(privateKeyDer.toString("base64"));
|
||||
console.log("");
|
||||
console.log("Private key PEM (reference, for license signing only):");
|
||||
console.log(privateKeyPem);
|
||||
console.log("");
|
||||
console.log("LicenseVerifier public key base64 (raw 32-byte X, step 4):");
|
||||
console.log(rawPublic.toString("base64"));
|
||||
console.log("──────────────────────────────────────────────────────────");
|
||||
|
||||
// Sanity check: sign + verify round trip with the exported artifacts.
|
||||
const publicKeyFromPem = createPrivateKey(privateKeyPem)
|
||||
.export({ format: "der", type: "pkcs8" });
|
||||
if (Buffer.compare(Buffer.from(privateKeyDer), Buffer.from(publicKeyFromPem)) !== 0) {
|
||||
console.error("Keypair export sanity check failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Sanity check passed.");
|
||||
@@ -1,28 +0,0 @@
|
||||
import { nookSchemaBootstrap, grantLicense } from "~/server/nook";
|
||||
|
||||
// Mint a free The Nook license (gifting / comps). Defaults to 1 device.
|
||||
//
|
||||
// bun --env-file=.env scripts/grant-nook-license.ts \
|
||||
// --email friend@example.com [--devices 1]
|
||||
|
||||
const arg = (name: string) => {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i === -1 ? undefined : process.argv[i + 1];
|
||||
};
|
||||
|
||||
const email = arg("email");
|
||||
const devices = Number(arg("devices") ?? "1");
|
||||
|
||||
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
console.error(
|
||||
"Usage: bun --env-file=.env scripts/grant-nook-license.ts --email you@example.com [--devices 1]"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await nookSchemaBootstrap;
|
||||
const { key } = await grantLicense(email, devices);
|
||||
|
||||
console.log(`Granted The Nook license for ${email} (${devices} device(s)):`);
|
||||
console.log(key);
|
||||
console.log("Send it to them; they enter it in Settings > License.");
|
||||
@@ -96,6 +96,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
"───────────────────────────────────────────────────────────────────\n"
|
||||
);
|
||||
|
||||
// Compare each page
|
||||
for (const baseResult of baseline.results) {
|
||||
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
||||
if (!optResult) continue;
|
||||
@@ -106,6 +107,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
console.log(`\n📄 ${baseResult.page}`);
|
||||
console.log("─".repeat(70));
|
||||
|
||||
// Core Web Vitals
|
||||
console.log("\n Core Web Vitals:");
|
||||
|
||||
const fcpDiff = opt.fcp - base.fcp;
|
||||
@@ -119,6 +121,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
||||
);
|
||||
|
||||
// Loading Metrics
|
||||
console.log("\n Loading Metrics:");
|
||||
|
||||
const ttfbDiff = opt.ttfb - base.ttfb;
|
||||
@@ -145,6 +148,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
||||
);
|
||||
|
||||
// Resource Loading
|
||||
console.log("\n Resources:");
|
||||
|
||||
const reqDiff = opt.totalRequests - base.totalRequests;
|
||||
@@ -181,6 +185,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
);
|
||||
}
|
||||
|
||||
// Overall Summary
|
||||
console.log(
|
||||
"\n\n═══════════════════════════════════════════════════════════════════"
|
||||
);
|
||||
@@ -333,6 +338,7 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
||||
);
|
||||
}
|
||||
|
||||
// Specific findings
|
||||
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
||||
if (reqPercent < -5) {
|
||||
console.log(
|
||||
|
||||
@@ -61,6 +61,7 @@ const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
||||
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
||||
const WARMUP_RUNS = 1;
|
||||
|
||||
// Pages to test
|
||||
const TEST_PAGES: PageTestConfig[] = [
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Blog Index", path: "/blog" },
|
||||
@@ -77,6 +78,7 @@ const TEST_PAGES: PageTestConfig[] = [
|
||||
{ name: "404", path: "/404" }
|
||||
];
|
||||
|
||||
// Add additional blog post path if provided
|
||||
if (process.env.TEST_BLOG_POST) {
|
||||
TEST_PAGES.push({
|
||||
name: "Custom Blog Post",
|
||||
@@ -100,6 +102,7 @@ async function setupPerformanceObservers(page: Page) {
|
||||
interactions: [] as number[]
|
||||
};
|
||||
|
||||
// Observe LCP
|
||||
if ("PerformanceObserver" in window) {
|
||||
try {
|
||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||
@@ -115,8 +118,10 @@ async function setupPerformanceObservers(page: Page) {
|
||||
buffered: true
|
||||
});
|
||||
} catch (e) {
|
||||
// LCP not supported
|
||||
}
|
||||
|
||||
// Observe CLS
|
||||
try {
|
||||
const clsObserver = new PerformanceObserver((entryList) => {
|
||||
for (const entry of entryList.getEntries()) {
|
||||
@@ -133,8 +138,10 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
clsObserver.observe({ type: "layout-shift", buffered: true });
|
||||
} catch (e) {
|
||||
// CLS not supported
|
||||
}
|
||||
|
||||
// Observe FID (first input)
|
||||
try {
|
||||
const fidObserver = new PerformanceObserver((entryList) => {
|
||||
const firstInput = entryList.getEntries()[0] as any;
|
||||
@@ -147,8 +154,10 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
fidObserver.observe({ type: "first-input", buffered: true });
|
||||
} catch (e) {
|
||||
// FID not supported
|
||||
}
|
||||
|
||||
// Observe long tasks
|
||||
try {
|
||||
const longTaskObserver = new PerformanceObserver((entryList) => {
|
||||
for (const entry of entryList.getEntries()) {
|
||||
@@ -157,8 +166,10 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
longTaskObserver.observe({ type: "longtask", buffered: true });
|
||||
} catch (e) {
|
||||
// Long tasks not supported
|
||||
}
|
||||
|
||||
// Observe INP (event timing for interactions)
|
||||
try {
|
||||
const inpObserver = new PerformanceObserver((entryList) => {
|
||||
for (const entry of entryList.getEntries()) {
|
||||
@@ -183,6 +194,7 @@ async function setupPerformanceObservers(page: Page) {
|
||||
});
|
||||
inpObserver.observe({ type: "event", buffered: true });
|
||||
} catch (e) {
|
||||
// Event timing not supported
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -191,14 +203,18 @@ async function setupPerformanceObservers(page: Page) {
|
||||
async function collectPerformanceMetrics(
|
||||
page: Page
|
||||
): Promise<PerformanceMetrics> {
|
||||
// Wait for page to be loaded
|
||||
await page.waitForLoadState("load");
|
||||
|
||||
// Wait a bit longer for LCP to settle (it can change as content loads)
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Additional wait for any remaining network activity
|
||||
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
||||
// Ignore timeout - networkidle may never happen for some pages
|
||||
});
|
||||
|
||||
// Collect comprehensive performance metrics
|
||||
const metrics = await page.evaluate(() => {
|
||||
const perf = performance.getEntriesByType(
|
||||
"navigation"
|
||||
@@ -206,6 +222,7 @@ async function collectPerformanceMetrics(
|
||||
const paint = performance.getEntriesByType("paint");
|
||||
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
||||
|
||||
// Get metrics from our observers
|
||||
const observedMetrics = (window as any).__perfMetrics || {
|
||||
lcp: 0,
|
||||
cls: 0,
|
||||
@@ -215,6 +232,7 @@ async function collectPerformanceMetrics(
|
||||
interactions: []
|
||||
};
|
||||
|
||||
// Fallback to direct API if observers didn't capture anything
|
||||
let lcp = observedMetrics.lcp;
|
||||
let cls = observedMetrics.cls;
|
||||
let fid = observedMetrics.fid;
|
||||
@@ -240,6 +258,7 @@ async function collectPerformanceMetrics(
|
||||
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
||||
}
|
||||
|
||||
// Calculate INP from event timing entries if not already captured
|
||||
if (inp === 0) {
|
||||
const eventEntries = performance.getEntriesByType("event") as any[];
|
||||
const interactionLatencies = eventEntries
|
||||
@@ -256,6 +275,7 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
// Get resource timing
|
||||
const resources = performance.getEntriesByType(
|
||||
"resource"
|
||||
) as PerformanceResourceTiming[];
|
||||
@@ -298,6 +318,7 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate long task duration
|
||||
let taskDuration = 0;
|
||||
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
||||
taskDuration = observedMetrics.longTasks.reduce(
|
||||
@@ -306,6 +327,7 @@ async function collectPerformanceMetrics(
|
||||
);
|
||||
}
|
||||
|
||||
// Get more granular performance entries
|
||||
let jsExecutionTime = 0;
|
||||
let layoutDuration = 0;
|
||||
let paintDuration = 0;
|
||||
@@ -317,6 +339,7 @@ async function collectPerformanceMetrics(
|
||||
}
|
||||
});
|
||||
|
||||
// Check for script evaluation entries
|
||||
const entries = performance.getEntries();
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.entryType === "measure") {
|
||||
@@ -376,6 +399,7 @@ async function testPagePerformance(
|
||||
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
||||
);
|
||||
|
||||
// Warmup runs (not counted)
|
||||
for (let i = 0; i < WARMUP_RUNS; i++) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
@@ -386,16 +410,20 @@ async function testPagePerformance(
|
||||
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
||||
}
|
||||
|
||||
// Measured runs
|
||||
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
||||
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
||||
|
||||
// Create new context for each run to ensure clean state
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
// Setup performance observers before navigation
|
||||
await setupPerformanceObservers(page);
|
||||
|
||||
// Navigate and collect metrics
|
||||
await page.goto(url, { waitUntil: "load" });
|
||||
const metrics = await collectPerformanceMetrics(page);
|
||||
|
||||
@@ -408,6 +436,7 @@ async function testPagePerformance(
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const average = calculateAverage(runs);
|
||||
const median = calculateMedian(runs);
|
||||
const p95 = calculatePercentile(runs, 95);
|
||||
@@ -619,6 +648,7 @@ function printResults(results: TestResult[]) {
|
||||
"═══════════════════════════════════════════════════════════════════\n"
|
||||
);
|
||||
|
||||
// Overall averages
|
||||
const overallAverage = {
|
||||
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
||||
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
||||
@@ -666,6 +696,7 @@ function printResults(results: TestResult[]) {
|
||||
|
||||
console.log("\n Optimization Opportunities:");
|
||||
|
||||
// Find pages with highest JS bytes
|
||||
const highestJS = [...results].sort(
|
||||
(a, b) => b.median.jsBytes - a.median.jsBytes
|
||||
)[0];
|
||||
@@ -676,6 +707,7 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with slow LCP
|
||||
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
||||
if (slowLCP.length > 0) {
|
||||
console.log(
|
||||
@@ -683,6 +715,7 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with high CLS
|
||||
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
||||
if (highCLS.length > 0) {
|
||||
console.log(
|
||||
@@ -690,6 +723,7 @@ function printResults(results: TestResult[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Find pages with high INP
|
||||
const highINP = results.filter((r) => r.median.inp > 200);
|
||||
if (highINP.length > 0) {
|
||||
console.log(
|
||||
@@ -706,6 +740,7 @@ async function main() {
|
||||
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
||||
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
||||
|
||||
// Check if server is running
|
||||
try {
|
||||
const response = await fetch(BASE_URL);
|
||||
if (!response.ok) {
|
||||
@@ -735,8 +770,10 @@ async function main() {
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Print results
|
||||
printResults(results);
|
||||
|
||||
// Save results to JSON file
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// One-off: upload the backfilled appcast to the download bucket.
|
||||
// Usage: cd ~/Code/freno-dev && bun --env-file=.env /tmp/upload-appcast.ts <file>
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [, , file] = process.argv;
|
||||
if (!file) {
|
||||
console.error("usage: bun --env-file=.env upload-appcast.ts <appcast.xml>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = readFileSync(file);
|
||||
const xml = body.toString("utf8");
|
||||
// Guard: refuse to upload without all three release descriptions present.
|
||||
for (const v of ["0.6.0", "0.6.2", "0.6.3"]) {
|
||||
if (!xml.includes(`sparkle:shortVersionString>${v}<`)) {
|
||||
console.error(`refusing: item for ${v} missing`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const client = new S3Client({
|
||||
region: process.env.AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: process.env.MY_AWS_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.MY_AWS_SECRET_KEY!
|
||||
}
|
||||
});
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env.VITE_DOWNLOAD_BUCKET_STRING,
|
||||
Key: "api/TheNook/appcast.xml",
|
||||
Body: body,
|
||||
ContentType: "application/xml; charset=utf-8"
|
||||
})
|
||||
);
|
||||
console.log("uploaded", file, "->", "api/TheNook/appcast.xml");
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live probe for the freno-dev deploy path (FRE-1695). Read-only; no credentials.
|
||||
#
|
||||
# Proves, in one run:
|
||||
# 1. Gitea master and GitHub master are the same commit (the mirror converged).
|
||||
# 2. The root domain answers on its own terms: https://freno.me/ returns a direct 200,
|
||||
# so a broken root deploy cannot read as green while the subdomains look fine.
|
||||
# 3. The live site serves copy that only exists on the reconciled line:
|
||||
# NOOK-1038's scoped remote claim (GitHub-only before the merge) *and*
|
||||
# FRE-997's "Supported agents" (Gitea-only before the merge). Both at once
|
||||
# can only come from the merge commit.
|
||||
# 4. The served tree therefore contains FRE-371 (`2fd2a4a`), which is an
|
||||
# ancestor of that merge — checked locally when the commit is in the object
|
||||
# store, so run this from a freno-dev checkout.
|
||||
#
|
||||
# Usage: scripts/verify-deploy.sh [expected-gitea-sha]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
GITEA_REMOTE="${GITEA_REMOTE:-https://git.freno.me/Mike/freno-dev.git}"
|
||||
GITHUB_REMOTE="${GITHUB_REMOTE:-https://github.com/mikefreno/freno-dev.git}"
|
||||
ROOT_HOST="${ROOT_HOST:-https://freno.me}"
|
||||
NOOK_HOST="${NOOK_HOST:-https://nook.freno.me}"
|
||||
NESSA_HOST="${NESSA_HOST:-https://nessa.freno.me}"
|
||||
|
||||
fail=0
|
||||
ok() { printf 'ok %s\n' "$1"; }
|
||||
bad() { printf 'FAIL %s\n' "$1"; fail=1; }
|
||||
|
||||
echo "== remotes =="
|
||||
gitea_sha=$(git ls-remote "$GITEA_REMOTE" refs/heads/master | cut -f1)
|
||||
github_sha=$(git ls-remote "$GITHUB_REMOTE" refs/heads/master | cut -f1)
|
||||
printf 'gitea master %s\ngithub master %s\n' "${gitea_sha:-<none>}" "${github_sha:-<none>}"
|
||||
|
||||
[ -n "$gitea_sha" ] && ok "gitea master readable" || bad "gitea master unreadable"
|
||||
if [ -n "$gitea_sha" ] && [ "$gitea_sha" = "$github_sha" ]; then
|
||||
ok "github master is the same commit (mirror converged)"
|
||||
else
|
||||
bad "github master differs from gitea master — the mirror has not run"
|
||||
fi
|
||||
|
||||
if [ -n "${1:-}" ]; then
|
||||
if [ "$1" = "$gitea_sha" ]; then ok "gitea master is the expected commit $1"
|
||||
else bad "gitea master is $gitea_sha, expected $1"; fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== live copy =="
|
||||
# The root is every other surface's entry point and deploys from the same tree, so it is
|
||||
# probed first. No -L: a redirect away from 200 is the failure we want to see, not a pass.
|
||||
root_code=$(curl -sS -o /dev/null -w '%{http_code}' "$ROOT_HOST/" 2>/dev/null)
|
||||
if [ "$root_code" = "200" ]; then
|
||||
ok "$ROOT_HOST/ serves 200"
|
||||
elif [ -z "$root_code" ] || [ "$root_code" = "000" ]; then
|
||||
bad "$ROOT_HOST/ gave no response, expected 200"
|
||||
else
|
||||
bad "$ROOT_HOST/ returned $root_code, expected 200 (green subdomains do not cover the root)"
|
||||
fi
|
||||
|
||||
page=$(curl -sS "$NOOK_HOST/" 2>/dev/null)
|
||||
case "$page" in
|
||||
*"One-click setup for Tailscale and authorized known hosts"*)
|
||||
ok "nook landing carries NOOK-1038's scoped remote claim" ;;
|
||||
*) bad "nook landing is missing NOOK-1038's scoped remote claim" ;;
|
||||
esac
|
||||
|
||||
agents=$(curl -sS "$NOOK_HOST/agents" 2>/dev/null)
|
||||
case "$agents" in
|
||||
*"Supported agents"*) ok "nook /agents carries FRE-997's restored coverage title" ;;
|
||||
*) bad "nook /agents still serves the pre-FRE-997 title" ;;
|
||||
esac
|
||||
|
||||
terms_code=$(curl -sS -o /tmp/verify-deploy-terms.$$ -w '%{http_code}' "$NESSA_HOST/terms" 2>/dev/null)
|
||||
if [ "$terms_code" = "200" ] && grep -q 'nessa-support@freno.me' /tmp/verify-deploy-terms.$$; then
|
||||
ok "nessa /terms is published and names the support address (FRE-1670)"
|
||||
else
|
||||
bad "nessa /terms returned $terms_code without the support address (FRE-1670 undeployed)"
|
||||
fi
|
||||
rm -f /tmp/verify-deploy-terms.$$
|
||||
|
||||
echo
|
||||
echo "== ancestry =="
|
||||
for commit in 2fd2a4a 7fde259; do
|
||||
if git cat-file -e "${commit}^{commit}" 2>/dev/null; then
|
||||
if git merge-base --is-ancestor "$commit" "$gitea_sha" 2>/dev/null; then
|
||||
ok "$commit is an ancestor of gitea master"
|
||||
else
|
||||
bad "$commit is NOT an ancestor of gitea master"
|
||||
fi
|
||||
else
|
||||
printf 'skip %s not in this clone; run from a freno-dev checkout to check ancestry\n' "$commit"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "PASS — the deploy follows gitea master and the served tree contains FRE-371."
|
||||
else
|
||||
echo "FAIL — see the lines above."
|
||||
fi
|
||||
exit "$fail"
|
||||
@@ -1,159 +0,0 @@
|
||||
import { createClient } from "@libsql/client/web";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import {
|
||||
ensureAnalyticsSchema,
|
||||
getWeeklyReport,
|
||||
renderWeeklyMarkdown,
|
||||
type Connections
|
||||
} from "~/server/weekly-metrics";
|
||||
|
||||
/**
|
||||
* Write `marketing/metrics/weekly.md` from the live analytics and licence DBs.
|
||||
*
|
||||
* bun --env-file=.env scripts/weekly-metrics.ts
|
||||
* bun --env-file=.env scripts/weekly-metrics.ts --days 14 --now 2026-11-05T09:00:00Z
|
||||
*
|
||||
* Required environment (all four; the analytics token also opens the licence
|
||||
* DB, which is why the weekly job can run from a checkout that has no Stripe
|
||||
* or Apple secrets):
|
||||
*
|
||||
* TURSO_DB_URL / TURSO_DB_TOKEN freno.me DB — VisitorAnalytics
|
||||
* NOOK_DB_URL / NOOK_DB_TOKEN the-nook DB — trials, licenses, activations
|
||||
*
|
||||
* This script deliberately does not import `~/env/server`: that schema demands
|
||||
* every production secret, and the metrics job needs two databases and nothing
|
||||
* else.
|
||||
*
|
||||
* The output path is anchored at the repo root, so running it from any cwd
|
||||
* writes the same file.
|
||||
*/
|
||||
|
||||
interface Options {
|
||||
days: number;
|
||||
now: Date;
|
||||
out: string;
|
||||
stdout: boolean;
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: bun --env-file=.env scripts/weekly-metrics.ts [options]",
|
||||
"",
|
||||
" --days N window length in days (default 7)",
|
||||
" --now ISO end of the window (default: now)",
|
||||
" --out PATH where to write (default: marketing/metrics/weekly.md)",
|
||||
" --stdout print the report instead of writing a file"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const repoRoot = resolve(import.meta.dir, "..");
|
||||
const options: Options = {
|
||||
days: 7,
|
||||
now: new Date(),
|
||||
out: resolve(repoRoot, "marketing/metrics/weekly.md"),
|
||||
stdout: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!;
|
||||
const value = () => {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) throw new Error(`${arg} needs a value\n\n${usage()}`);
|
||||
i += 1;
|
||||
return next;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case "--days": {
|
||||
const days = Number(value());
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
throw new Error(`--days must be a positive number\n\n${usage()}`);
|
||||
}
|
||||
options.days = days;
|
||||
break;
|
||||
}
|
||||
case "--now": {
|
||||
const now = new Date(value());
|
||||
if (Number.isNaN(now.getTime())) {
|
||||
throw new Error(`--now must be an ISO timestamp\n\n${usage()}`);
|
||||
}
|
||||
options.now = now;
|
||||
break;
|
||||
}
|
||||
case "--out":
|
||||
options.out = resolve(value());
|
||||
break;
|
||||
case "--stdout":
|
||||
options.stdout = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
throw new Error(`Unknown argument ${arg}\n\n${usage()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value || value.trim() === "") {
|
||||
throw new Error(
|
||||
`${name} is not set. The weekly job needs TURSO_DB_URL, TURSO_DB_TOKEN, ` +
|
||||
`NOOK_DB_URL and NOOK_DB_TOKEN — run it as ` +
|
||||
`\`bun --env-file=.env scripts/weekly-metrics.ts\` with those four in .env.`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function connect(url: string, token: string) {
|
||||
return createClient({ url, authToken: token });
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
const connections: Connections = {
|
||||
analytics: connect(requiredEnv("TURSO_DB_URL"), requiredEnv("TURSO_DB_TOKEN")),
|
||||
nook: connect(requiredEnv("NOOK_DB_URL"), requiredEnv("NOOK_DB_TOKEN"))
|
||||
};
|
||||
|
||||
// The app migrates the table on boot, but this job must not depend on a
|
||||
// deploy having happened: add any missing post-hoc columns (stage, host)
|
||||
// through the same idempotent bootstrap path first.
|
||||
await ensureAnalyticsSchema(connections.analytics);
|
||||
|
||||
const report = await getWeeklyReport(connections, {
|
||||
days: options.days,
|
||||
now: options.now
|
||||
});
|
||||
const markdown = renderWeeklyMarkdown(report);
|
||||
|
||||
if (options.stdout) {
|
||||
process.stdout.write(markdown);
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(dirname(options.out), { recursive: true });
|
||||
await writeFile(options.out, markdown, "utf8");
|
||||
|
||||
const f = report.counts;
|
||||
console.log(
|
||||
`wrote ${options.out}\n` +
|
||||
`window ${report.window.start} → ${report.window.end}\n` +
|
||||
`visit ${f.visit}, download click ${f.downloadClick}, DMG served ${f.dmgServed}, ` +
|
||||
`trial ${f.trial}, checkout ${f.checkout}, purchase ${f.purchase}, activation ${f.activation}\n` +
|
||||
`mover: ${report.mover ? `${report.mover.label} ${report.mover.before} → ${report.mover.now}` : "none"}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
315
src/app.css
@@ -74,11 +74,6 @@
|
||||
--color-base: #fbf1c7;
|
||||
--color-mantle: #f3eac1;
|
||||
--color-crust: #e7deb7;
|
||||
|
||||
/* Button text colors: white on dark bg variants (primary/danger),
|
||||
theme text on light bg variants (secondary). */
|
||||
--color-button-text: #ffffff;
|
||||
--color-button-text-alt: var(--color-text);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -109,11 +104,6 @@
|
||||
--color-base: #1e1e2e;
|
||||
--color-mantle: #141620;
|
||||
--color-crust: #0e0f16;
|
||||
|
||||
/* Dark mode: dark text on lighter bg variants (primary/danger),
|
||||
white text on dark bg variants (secondary). */
|
||||
--color-button-text: var(--color-crust);
|
||||
--color-button-text-alt: #ffffff;
|
||||
}
|
||||
@theme {
|
||||
--color-rosewater: #efc9c2;
|
||||
@@ -173,8 +163,6 @@
|
||||
--color-base: #fbf1c7;
|
||||
--color-mantle: #f3eac1;
|
||||
--color-crust: #e7deb7;
|
||||
--color-button-text: #ffffff;
|
||||
--color-button-text-alt: var(--color-text);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
@@ -204,8 +192,6 @@
|
||||
--color-base: #1e1e2e;
|
||||
--color-mantle: #141620;
|
||||
--color-crust: #0e0f16;
|
||||
--color-button-text: var(--color-crust);
|
||||
--color-button-text-alt: #ffffff;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -574,304 +560,3 @@ a.hover-underline-animation:hover::after {
|
||||
.shaker:hover {
|
||||
animation: shaker 0.5s ease;
|
||||
}
|
||||
|
||||
/* ── Nook landing: campfire sprite frame cycling ──────────────────── */
|
||||
/* Each frame layer is stacked; its animation holds opacity 1 during its
|
||||
slot and 0 otherwise, so the stack reads as stop-motion at the app's
|
||||
sprite cadence (frame time = total duration / frame count). The phase
|
||||
lives inside the keyframes and every layer animates delay-free from the
|
||||
same clock WebKit quantizes animation starts per cycle wrap, so
|
||||
delay-offset layers can drop a wrap frame and show BOTH transparent
|
||||
for a frame (the "blank frame" flicker). Complementary holds mean any
|
||||
moment sums to exactly one opaque layer. Linear timing: steps()
|
||||
holds mis-swap at boundary frames under WebKit. */
|
||||
/* 2-frame sprites: layer i uses campfire-slot-2-{a,b}. */
|
||||
@keyframes campfire-slot-2-a {
|
||||
0%,
|
||||
49.99% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes campfire-slot-2-b {
|
||||
0%,
|
||||
49.99% {
|
||||
opacity: 0;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 6-frame sprites (question): six slot phases. */
|
||||
@keyframes campfire-slot-6-a {
|
||||
0%,
|
||||
16.66% {
|
||||
opacity: 1;
|
||||
}
|
||||
16.67%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes campfire-slot-6-b {
|
||||
0%,
|
||||
16.66% {
|
||||
opacity: 0;
|
||||
}
|
||||
16.67%,
|
||||
33.32% {
|
||||
opacity: 1;
|
||||
}
|
||||
33.33%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes campfire-slot-6-c {
|
||||
0%,
|
||||
33.32% {
|
||||
opacity: 0;
|
||||
}
|
||||
33.33%,
|
||||
49.99% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes campfire-slot-6-d {
|
||||
0%,
|
||||
49.99% {
|
||||
opacity: 0;
|
||||
}
|
||||
50%,
|
||||
66.66% {
|
||||
opacity: 1;
|
||||
}
|
||||
66.67%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes campfire-slot-6-e {
|
||||
0%,
|
||||
66.66% {
|
||||
opacity: 0;
|
||||
}
|
||||
66.67%,
|
||||
83.32% {
|
||||
opacity: 1;
|
||||
}
|
||||
83.33%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes campfire-slot-6-f {
|
||||
0%,
|
||||
83.32% {
|
||||
opacity: 0;
|
||||
}
|
||||
83.33%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.campfire-frame {
|
||||
animation: var(--slot-kf, campfire-slot-2-a) var(--campfire-duration, 1s)
|
||||
linear infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Nook landing: breakdown section reveal ───────────────────────── */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition:
|
||||
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.reveal.in {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.campfire-frame {
|
||||
animation: none;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.campfire-frame + .campfire-frame {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* ── Nook landing: fan card shake, builds with fan speed ───────── */
|
||||
.fan-card-shake {
|
||||
transform-origin: left center;
|
||||
animation-name: fan-shake;
|
||||
animation-duration: var(--fan-speed, 0.4s);
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fan-shake {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(calc(var(--fan-amp, 0deg) * -1)); }
|
||||
}
|
||||
|
||||
/* Sputtering sparks off the fan card at max speed */
|
||||
.fan-spark {
|
||||
position: absolute;
|
||||
border-radius: 9999px;
|
||||
background: #ffd23f;
|
||||
box-shadow: 0 0 4px 1px rgba(255, 140, 0, 0.9);
|
||||
animation: fan-spark-fly var(--sd, 0.5s) ease-out var(--sdel, 0s) infinite;
|
||||
}
|
||||
@keyframes fan-spark-fly {
|
||||
0% { transform: translate(0, 0) scale(1); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
100% { transform: translate(var(--sx, 20px), var(--sy, 30px)) scale(0.4); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Demo pulse: battery bolt */
|
||||
@keyframes nook-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* ── Nook landing: newer demos ─────────────────────────────────── */
|
||||
.nook-caret {
|
||||
animation: nook-pulse 0.8s steps(2, jump-none) infinite;
|
||||
}
|
||||
|
||||
/* Directional dashes in the power-flow preview. */
|
||||
.nook-power-flow {
|
||||
animation: nook-power-flow 1.6s linear infinite;
|
||||
}
|
||||
@keyframes nook-power-flow {
|
||||
to { stroke-dashoffset: -21; }
|
||||
}
|
||||
|
||||
/* Live signal inside a mixer fader */
|
||||
.nook-meter {
|
||||
transform-origin: left center;
|
||||
animation: nook-meter 0.6s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-meter {
|
||||
0% { transform: scaleX(0.35); }
|
||||
40% { transform: scaleX(0.8); }
|
||||
100% { transform: scaleX(0.55); }
|
||||
}
|
||||
|
||||
/* Waveform slot bars */
|
||||
.nook-wave {
|
||||
height: 100%;
|
||||
transform-origin: center;
|
||||
animation: nook-wave 0.8s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-wave {
|
||||
0% { transform: scaleY(0.25); }
|
||||
100% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* Now Playing progress and artwork sheen */
|
||||
.nook-progress {
|
||||
animation: nook-progress 14s linear infinite;
|
||||
}
|
||||
@keyframes nook-progress {
|
||||
from { width: 8%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
.nook-art {
|
||||
animation: nook-art 9s linear infinite;
|
||||
}
|
||||
@keyframes nook-art {
|
||||
to { filter: hue-rotate(360deg); }
|
||||
}
|
||||
|
||||
/* A ringing timer's tape breathes between 35 % and full */
|
||||
.nook-tape-ring {
|
||||
animation: nook-tape-ring 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes nook-tape-ring {
|
||||
from { opacity: 0.35; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Keep Awake coffee steam */
|
||||
.nook-steam path {
|
||||
animation: nook-steam 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes nook-steam {
|
||||
0% { transform: translateY(2px); opacity: 0; }
|
||||
40% { opacity: 1; }
|
||||
100% { transform: translateY(-4px); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Two-way feature marquee; edges fade, hover pauses */
|
||||
.nook-marquee {
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||
}
|
||||
.nook-marquee-track {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
width: max-content;
|
||||
padding-right: 0.625rem;
|
||||
animation: nook-marquee 60s linear infinite;
|
||||
}
|
||||
.nook-marquee-track.reverse {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
.nook-marquee:hover .nook-marquee-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@keyframes nook-marquee {
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nook-power-flow,
|
||||
.nook-meter,
|
||||
.nook-wave,
|
||||
.nook-progress,
|
||||
.nook-art,
|
||||
.nook-tape-ring,
|
||||
.nook-steam path,
|
||||
.nook-caret {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.nook-marquee {
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.nook-marquee-track {
|
||||
animation: none;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.nook-marquee-track > [aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
26
src/app.tsx
@@ -11,7 +11,6 @@ import {
|
||||
import "./app.css";
|
||||
import { LeftBar, RightBar } from "./components/Bars";
|
||||
import { TerminalSplash } from "./components/TerminalSplash";
|
||||
import SubdomainFooter from "./components/SubdomainFooter";
|
||||
import { MetaProvider } from "@solidjs/meta";
|
||||
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
|
||||
import { BarsProvider, useBars } from "./context/bars";
|
||||
@@ -35,8 +34,10 @@ function AppLayout(props: { children: any }) {
|
||||
let lastScrollY = 0;
|
||||
|
||||
onMount(() => {
|
||||
// Initialize performance tracking
|
||||
initPerformanceTracking();
|
||||
|
||||
// Start monitoring for new deployments
|
||||
startDeploymentMonitoring();
|
||||
|
||||
const windowWidth = createWindowWidth();
|
||||
@@ -198,24 +199,21 @@ function AppLayout(props: { children: any }) {
|
||||
</Show>
|
||||
|
||||
<Show when={!isMainSite()}>
|
||||
<div class="bg-base flex min-h-screen w-full flex-col overflow-x-hidden">
|
||||
<div class="bg-base min-h-screen w-full overflow-x-hidden">
|
||||
<noscript>
|
||||
<div class="bg-yellow text-crust border-text fixed top-0 z-150 w-full border-b-2 p-4 text-center font-semibold">
|
||||
JavaScript is disabled. Features will be limited.
|
||||
</div>
|
||||
</noscript>
|
||||
<div class="flex-1">
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorBoundaryFallback error={error} reset={reset} />
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<TerminalSplash inverse />}>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<SubdomainFooter />
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorBoundaryFallback error={error} reset={reset} />
|
||||
)}
|
||||
>
|
||||
<Suspense fallback={<TerminalSplash inverse />}>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
|
||||
@@ -366,11 +366,6 @@ function MainRightBarContent() {
|
||||
Contact Me
|
||||
</a>
|
||||
</li>
|
||||
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
|
||||
<a href="/privacy-policy" onClick={handleLinkClick}>
|
||||
Privacy
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/MikeFreno/"
|
||||
@@ -465,7 +460,7 @@ function MainRightBarContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function RightBarContent() {
|
||||
export function RightBarContent() {
|
||||
const site = useSite();
|
||||
return (
|
||||
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>
|
||||
|
||||
@@ -144,6 +144,7 @@ const sendContactEmail = action(async (formData: FormData) => {
|
||||
|
||||
const { env } = await import("~/env/server");
|
||||
|
||||
// Verify Cloudflare Turnstile token
|
||||
const turnstileValid = await verifyTurnstileToken(
|
||||
turnstileToken,
|
||||
env.TURNSTILE_SECRET_KEY,
|
||||
@@ -251,6 +252,7 @@ export function ContactForm(props: ContactFormProps) {
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Load server data using createAsync
|
||||
const contactData = createAsync(() => getContactData(), {
|
||||
deferStream: true
|
||||
});
|
||||
@@ -348,6 +350,7 @@ export function ContactForm(props: ContactFormProps) {
|
||||
const message = formData.get("message") as string;
|
||||
|
||||
if (name && email && message) {
|
||||
// Get fresh Turnstile token
|
||||
let currentToken = turnstileToken();
|
||||
if (
|
||||
!currentToken &&
|
||||
@@ -385,6 +388,7 @@ export function ContactForm(props: ContactFormProps) {
|
||||
setError("");
|
||||
form.reset();
|
||||
|
||||
// Reset Turnstile widget
|
||||
if (typeof window !== "undefined" && (window as any).turnstile) {
|
||||
const widgetEl = document.getElementById("turnstile-widget-1");
|
||||
if (widgetEl) {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { HttpHeader } from "@solidjs/start";
|
||||
|
||||
/**
|
||||
* Renders edge-cache response headers for a page route.
|
||||
*
|
||||
* Sets `CDN-Cache-Control` so Vercel serves the rendered HTML from the edge
|
||||
* (no origin re-render) for `maxAge` seconds, then stale-while-revalidate up
|
||||
* to `staleSeconds`. `Cache-Control: public, max-age=0` keeps browsers
|
||||
* revalidating, so visitors always get the latest version — only the CDN
|
||||
* holds a cached copy. Function/CND-Cache-Control overrides the Vercel
|
||||
* default, so repeated visits and bot crawls stop re-rendering at origin.
|
||||
*/
|
||||
export function EdgeCacheHeaders(props: {
|
||||
/** Seconds the CDN may serve the response fresh. */
|
||||
maxAge: number;
|
||||
/** Seconds the CDN serves stale while revalidating (default: 1 day). */
|
||||
staleSeconds?: number;
|
||||
}) {
|
||||
const stale = props.staleSeconds ?? 86400;
|
||||
return (
|
||||
<>
|
||||
<HttpHeader name="Cache-Control" value="public, max-age=0" />
|
||||
<HttpHeader
|
||||
name="CDN-Cache-Control"
|
||||
value={`public, s-maxage=${props.maxAge}, stale-while-revalidate=${stale}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -60,42 +60,6 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
|
||||
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
|
||||
});
|
||||
|
||||
// Subdomain routing is host-aware (root routes dispatch by useSite()), so
|
||||
// useLocation() normally reports the public path. resolvePageHeadMeta also
|
||||
// defensively strips a subdomain prefix from a prefixed pathname, so the
|
||||
// canonical stays public even if the prefixed form ever appears.
|
||||
it("nessa prefixed /nessa/contact → https://nessa.freno.me/contact", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
BASE_PROPS,
|
||||
SITE_CONFIG.nessa,
|
||||
"/nessa/contact"
|
||||
);
|
||||
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
|
||||
});
|
||||
|
||||
it("lineage prefixed /lineage/privacy → https://lineage.freno.me/privacy", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
BASE_PROPS,
|
||||
SITE_CONFIG.lineage,
|
||||
"/lineage/privacy"
|
||||
);
|
||||
expect(meta.canonical).toBe("https://lineage.freno.me/privacy");
|
||||
});
|
||||
|
||||
it("nessa prefixed root /nessa/ → https://nessa.freno.me/", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/nessa/");
|
||||
expect(meta.canonical).toBe("https://nessa.freno.me/");
|
||||
});
|
||||
|
||||
it("main path that happens to start with /nessa is NOT stripped (main site has no prefix)", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
BASE_PROPS,
|
||||
SITE_CONFIG.main,
|
||||
"/nessa/contact"
|
||||
);
|
||||
expect(meta.canonical).toBe("https://freno.me/nessa/contact");
|
||||
});
|
||||
|
||||
it("lineage → canonical starts with https://lineage.freno.me", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.lineage, "/");
|
||||
expect(meta.canonical.startsWith("https://lineage.freno.me")).toBe(true);
|
||||
@@ -132,17 +96,12 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
|
||||
});
|
||||
|
||||
describe("resolvePageHeadMeta — OpenGraph fallbacks", () => {
|
||||
it("ogImage defaults to the site's ogDefaultImage, resolved to an absolute URL", () => {
|
||||
it("ogImage defaults to the site's ogDefaultImage when not provided", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/");
|
||||
expect(meta.ogImage).toBe("https://nessa.freno.me/nessa/og-default.png");
|
||||
expect(meta.ogImage).toBe(SITE_CONFIG.nessa.ogDefaultImage);
|
||||
});
|
||||
|
||||
it("the nook default resolves to the absolute og.png URL", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nook, "/");
|
||||
expect(meta.ogImage).toBe("https://nook.freno.me/nook/og.png");
|
||||
});
|
||||
|
||||
it("explicit absolute ogImage passes through unchanged", () => {
|
||||
it("explicit ogImage overrides the site default", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
{ ...BASE_PROPS, ogImage: "https://cdn/custom.png" },
|
||||
SITE_CONFIG.main,
|
||||
@@ -151,15 +110,6 @@ describe("resolvePageHeadMeta — OpenGraph fallbacks", () => {
|
||||
expect(meta.ogImage).toBe("https://cdn/custom.png");
|
||||
});
|
||||
|
||||
it("explicit site-relative ogImage is resolved against the site domain", () => {
|
||||
const meta = resolvePageHeadMeta(
|
||||
{ ...BASE_PROPS, ogImage: "/og/custom.png" },
|
||||
SITE_CONFIG.gaze,
|
||||
"/"
|
||||
);
|
||||
expect(meta.ogImage).toBe("https://gaze.freno.me/og/custom.png");
|
||||
});
|
||||
|
||||
it("ogTitle falls back to the base title (no suffix)", () => {
|
||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
||||
expect(meta.ogTitle).toBe("Blog");
|
||||
|
||||
@@ -56,8 +56,6 @@ export default function PageHead(props: PageHeadProps) {
|
||||
<Meta property="og:description" content={meta().ogDescription} />
|
||||
)}
|
||||
<Meta property="og:image" content={meta().ogImage} />
|
||||
{/* X/Twitter large-image card; falls back to the og:* tags above. */}
|
||||
<Meta name="twitter:card" content="summary_large_image" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { buildMainSiteUrl } from "~/lib/site-context";
|
||||
|
||||
export default function SubdomainFooter() {
|
||||
return (
|
||||
<footer class="border-surface0 bg-surface0 relative z-10 border-t py-8">
|
||||
<div class="relative flex flex-col items-center gap-2 text-sm sm:flex-row sm:justify-center">
|
||||
<A
|
||||
href={buildMainSiteUrl()}
|
||||
class="text-text/60 hover:text-text/80 text-center underline underline-offset-4 transition-colors"
|
||||
>
|
||||
made with <span class="text-red-400"><3</span>
|
||||
</A>
|
||||
<A
|
||||
href={buildMainSiteUrl("/downloads")}
|
||||
class="text-text/80 hover:text-text underline underline-offset-4 transition-colors sm:absolute sm:right-4"
|
||||
>
|
||||
see more products
|
||||
</A>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -12,13 +12,13 @@ import { For, Show } from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import { useSite } from "~/context/SiteContext";
|
||||
import { useDarkMode } from "~/context/darkMode";
|
||||
import { NAV_CONFIG } from "~/lib/nav-config";
|
||||
import { NAV_CONFIG, BACK_TO_FRENO } from "~/lib/nav-config";
|
||||
import { DarkModeToggle } from "~/components/DarkModeToggle";
|
||||
|
||||
export default function SubdomainHeader() {
|
||||
const site = useSite();
|
||||
const location = useLocation();
|
||||
const { isDark } = useDarkMode();
|
||||
const { isDark, toggleDarkMode } = useDarkMode();
|
||||
|
||||
const brandName = () => site().displayName;
|
||||
const brandColor = () =>
|
||||
@@ -32,7 +32,10 @@ export default function SubdomainHeader() {
|
||||
};
|
||||
|
||||
return (
|
||||
<header class="bg-base/80 border-surface0 sticky top-0 z-50 w-full border-b backdrop-blur-md">
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full border-b backdrop-blur-md"
|
||||
classList={{ "bg-base/80 border-surface0": site().headerOpaque }}
|
||||
>
|
||||
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||
<A
|
||||
href="/"
|
||||
@@ -75,7 +78,7 @@ export default function SubdomainHeader() {
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<DarkModeToggle shouldScale={false} />
|
||||
<DarkModeToggle />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -138,6 +138,9 @@ export function Typewriter(props: {
|
||||
entries.forEach((entry) => {
|
||||
// If component leaves viewport while animating, we could pause
|
||||
// For now, we just ensure it starts when visible
|
||||
if (!entry.isIntersecting && cleanupAnimation) {
|
||||
// Component is off-screen - could add pause logic here if needed
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Refresh the S3 file list
|
||||
await loadAttachments();
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -80,6 +81,7 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
||||
body: JSON.stringify({ key })
|
||||
});
|
||||
|
||||
// Refresh the S3 file list
|
||||
await loadAttachments();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete file:", err);
|
||||
|
||||
@@ -259,6 +259,7 @@ export default function CommentSectionWrapper(
|
||||
const newComment = async (commentBody: string, parentCommentID?: number) => {
|
||||
setCommentSubmitLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (commentSubmitTimeoutId) {
|
||||
clearTimeout(commentSubmitTimeoutId);
|
||||
}
|
||||
@@ -427,6 +428,7 @@ export default function CommentSectionWrapper(
|
||||
const editComment = async (body: string, comment_id: number) => {
|
||||
setCommentEditLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (editCommentTimeoutId) {
|
||||
clearTimeout(editCommentTimeoutId);
|
||||
}
|
||||
@@ -525,6 +527,7 @@ export default function CommentSectionWrapper(
|
||||
|
||||
setCommentDeletionLoading(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (deleteCommentTimeoutId) {
|
||||
clearTimeout(deleteCommentTimeoutId);
|
||||
}
|
||||
@@ -620,6 +623,7 @@ export default function CommentSectionWrapper(
|
||||
setOperationError("");
|
||||
|
||||
if (data.commentBody) {
|
||||
// Soft delete (replace body with deletion message)
|
||||
setAllComments((prev) =>
|
||||
prev.map((comment) => {
|
||||
if (comment.id === data.commentID) {
|
||||
@@ -648,6 +652,7 @@ export default function CommentSectionWrapper(
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Hard delete (remove from list)
|
||||
setAllComments((prev) =>
|
||||
prev.filter((comment) => comment.id !== data.commentID)
|
||||
);
|
||||
@@ -662,6 +667,7 @@ export default function CommentSectionWrapper(
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Deletion/edit prompt toggle
|
||||
const toggleModification = (
|
||||
commentID: number,
|
||||
commenterID: string,
|
||||
@@ -702,6 +708,7 @@ export default function CommentSectionWrapper(
|
||||
setCommentBodyForModification("");
|
||||
};
|
||||
|
||||
// Reaction handling
|
||||
const commentReaction = (reactionType: ReactionType, commentID: number) => {
|
||||
if (!props.currentUserID) {
|
||||
console.warn("Cannot react to comment: user not authenticated");
|
||||
@@ -793,6 +800,7 @@ export default function CommentSectionWrapper(
|
||||
}
|
||||
};
|
||||
|
||||
// Click outside handlers (SolidJS version)
|
||||
createEffect(() => {
|
||||
const handleClickOutsideDelete = (e: MouseEvent) => {
|
||||
if (
|
||||
|
||||
@@ -8,10 +8,12 @@ function sanitizeMermaidSvg(svgString: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgString, "text/html");
|
||||
|
||||
// Remove dangerous elements
|
||||
doc.querySelectorAll("script, iframe, object, embed, form, link, meta, base").forEach((el) => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
// Remove event handlers and dangerous attributes from all elements
|
||||
doc.querySelectorAll("[on*], [href*='javascript:'], [style*='expression(']").forEach((el) => {
|
||||
const attrs = Array.from(el.attributes);
|
||||
attrs.forEach((attr) => {
|
||||
|
||||
@@ -11,17 +11,15 @@ function sanitizeHtml(html: string): string {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
// Remove dangerous elements
|
||||
doc
|
||||
.querySelectorAll(
|
||||
"script, iframe, object, embed, form, link, meta, base, svg script"
|
||||
)
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
// Remove event handler attributes and dangerous URLs from all elements.
|
||||
// NOTE: attribute-name wildcards (e.g. [on*]) are not valid CSS selectors and
|
||||
// throw a SyntaxError on querySelectorAll, so we match all elements here and
|
||||
// the per-attribute checks below handle on*/href/style/action filtering.
|
||||
doc.querySelectorAll("*").forEach((el) => {
|
||||
// Remove event handler attributes and dangerous URLs from all elements
|
||||
doc.querySelectorAll("[on*], [href], [style], [action]").forEach((el) => {
|
||||
const attrs = Array.from(el.attributes);
|
||||
attrs.forEach((attr) => {
|
||||
const name = attr.name;
|
||||
@@ -130,6 +128,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
const processVideos = () => {
|
||||
if (!contentRef) return;
|
||||
|
||||
// Handle direct video elements
|
||||
const videoElements = contentRef.querySelectorAll("video");
|
||||
|
||||
videoElements.forEach((video) => {
|
||||
@@ -137,14 +136,18 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
video.setAttribute("playsinline", "");
|
||||
video.setAttribute("controls", "");
|
||||
|
||||
// Remove download attribute if present
|
||||
video.removeAttribute("download");
|
||||
|
||||
// Ensure proper MIME types on source elements
|
||||
const sources = video.querySelectorAll("source");
|
||||
sources.forEach((source) => {
|
||||
const src = source.getAttribute("src");
|
||||
if (src) {
|
||||
// Remove download attribute from sources
|
||||
source.removeAttribute("download");
|
||||
|
||||
// Set correct type attribute if missing
|
||||
if (!source.hasAttribute("type")) {
|
||||
if (src.endsWith(".mp4")) {
|
||||
source.setAttribute("type", "video/mp4");
|
||||
@@ -157,6 +160,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// If video has direct src attribute, ensure type is set
|
||||
const videoSrc = video.getAttribute("src");
|
||||
if (videoSrc && !video.hasAttribute("type")) {
|
||||
if (videoSrc.endsWith(".mp4")) {
|
||||
@@ -169,6 +173,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle iframes with video sources - replace with proper video tags
|
||||
const iframes = contentRef.querySelectorAll("iframe");
|
||||
iframes.forEach((iframe) => {
|
||||
const src = iframe.getAttribute("src");
|
||||
@@ -179,6 +184,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
src.endsWith(".webm") ||
|
||||
src.endsWith(".ogg"))
|
||||
) {
|
||||
// Create a proper video element
|
||||
const video = document.createElement("video");
|
||||
video.setAttribute("controls", "");
|
||||
video.setAttribute("playsinline", "");
|
||||
@@ -186,6 +192,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
video.style.maxWidth = "100%";
|
||||
video.style.height = "auto";
|
||||
|
||||
// Set appropriate type based on file extension
|
||||
let videoType = "video/mp4";
|
||||
if (src.endsWith(".mov")) {
|
||||
videoType = "video/mp4"; // MOV files are typically H.264 which plays as mp4
|
||||
@@ -198,6 +205,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
video.setAttribute("type", videoType);
|
||||
video.src = src;
|
||||
|
||||
// Replace the iframe with the video element
|
||||
const parent = iframe.parentElement;
|
||||
if (parent) {
|
||||
parent.replaceChild(video, iframe);
|
||||
@@ -205,6 +213,7 @@ export default function PostBodyClient(props: PostBodyClientProps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Also check for any anchor tags wrapping videos that might have download attribute
|
||||
const videoLinks = contentRef.querySelectorAll("a");
|
||||
videoLinks.forEach((link) => {
|
||||
const hasVideo = link.querySelector("video");
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function PostForm(props: PostFormProps) {
|
||||
props.postId
|
||||
);
|
||||
|
||||
// Mark initial load as complete after data is loaded (for edit mode)
|
||||
// Use setTimeout to ensure this runs after all signals are initialized
|
||||
createEffect(() => {
|
||||
if (props.mode === "edit" && props.initialData) {
|
||||
@@ -72,10 +73,12 @@ export default function PostForm(props: PostFormProps) {
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// Helper to ensure post exists (create if needed)
|
||||
const ensurePostExists = async (): Promise<number> => {
|
||||
const existingId = createdPostId() || props.postId;
|
||||
if (existingId) return existingId;
|
||||
|
||||
// Create minimal post if it doesn't exist yet
|
||||
const result = await api.database.createPost.mutate({
|
||||
category: "blog",
|
||||
title: title().replaceAll(" ", "_") || "Untitled",
|
||||
@@ -92,6 +95,7 @@ export default function PostForm(props: PostFormProps) {
|
||||
return newId;
|
||||
};
|
||||
|
||||
// Individual autosave functions for each field
|
||||
const autoSaveTitle = async () => {
|
||||
const currentTitle = title();
|
||||
if (!currentTitle || currentTitle === props.initialData?.title) return;
|
||||
@@ -244,6 +248,7 @@ export default function PostForm(props: PostFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced versions
|
||||
const debouncedAutoSaveTitle = debounce(autoSaveTitle, 2500);
|
||||
const debouncedAutoSaveSubtitle = debounce(autoSaveSubtitle, 2500);
|
||||
const debouncedAutoSaveBody = debounce(autoSaveBody, 2500);
|
||||
@@ -251,6 +256,7 @@ export default function PostForm(props: PostFormProps) {
|
||||
const debouncedAutoSavePublished = debounce(autoSavePublished, 1000);
|
||||
const debouncedAutoSaveBanner = debounce(autoSaveBanner, 2500);
|
||||
|
||||
// Individual effects for each field
|
||||
createEffect(() => {
|
||||
const titleVal = title();
|
||||
if (isInitialLoad()) return;
|
||||
@@ -399,6 +405,7 @@ export default function PostForm(props: PostFormProps) {
|
||||
author_id: props.userID
|
||||
});
|
||||
} else {
|
||||
// Create new post
|
||||
const result = await api.database.createPost.mutate({
|
||||
category: "blog",
|
||||
title: title().replaceAll(" ", "_"),
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function PostSorting(props: PostSortingProps) {
|
||||
case "newest":
|
||||
break; // Posts already come newest first from DB (DESC order)
|
||||
case "oldest":
|
||||
sorted.reverse();
|
||||
sorted.reverse(); // Reverse to get oldest first
|
||||
break;
|
||||
case "most_liked":
|
||||
sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0));
|
||||
|
||||
@@ -1547,6 +1547,7 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
},
|
||||
handleDOMEvents: {
|
||||
touchstart: (view, event) => {
|
||||
// Only handle touch events on mobile in fullscreen with active suggestion
|
||||
if (
|
||||
!hasSuggestion() ||
|
||||
!isFullscreen() ||
|
||||
@@ -1561,6 +1562,7 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
return false;
|
||||
},
|
||||
touchend: (view, event) => {
|
||||
// Only handle touch events on mobile in fullscreen with active suggestion
|
||||
if (
|
||||
!hasSuggestion() ||
|
||||
!isFullscreen() ||
|
||||
@@ -1858,6 +1860,7 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
const node = allSuperscriptNodes[i];
|
||||
const text = node.text;
|
||||
|
||||
// Check if this is a complete reference (with optional whitespace)
|
||||
const completeMatch = text.match(/^\s*\[(\d+)\]\s*$/);
|
||||
if (completeMatch) {
|
||||
const hasOtherMarks = node.marks.some(
|
||||
@@ -1874,6 +1877,7 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this might be the start of a split reference
|
||||
if (text === "[" && i + 2 < allSuperscriptNodes.length) {
|
||||
const nextNode = allSuperscriptNodes[i + 1];
|
||||
const afterNode = allSuperscriptNodes[i + 2];
|
||||
@@ -1954,6 +1958,7 @@ export default function TextEditor(props: TextEditorProps) {
|
||||
|
||||
allRefs.sort((a, b) => a.pos - b.pos);
|
||||
|
||||
// Check if renumbering is needed (if any ref doesn't match its expected number)
|
||||
let needsRenumbering = false;
|
||||
for (let i = 0; i < allRefs.length; i++) {
|
||||
if (allRefs[i].refNum !== i + 1) {
|
||||
|
||||
@@ -69,6 +69,7 @@ export const Mermaid = Node.create({
|
||||
getAttrs: (element) => {
|
||||
if (typeof element === "string") return false;
|
||||
|
||||
// Skip if already has data-type or data-mermaid-diagram attribute
|
||||
if (
|
||||
element.hasAttribute("data-type") ||
|
||||
element.hasAttribute("data-mermaid-diagram")
|
||||
@@ -82,6 +83,7 @@ export const Mermaid = Node.create({
|
||||
const content = code.textContent || "";
|
||||
const trimmedContent = content.trim();
|
||||
|
||||
// Check if this looks like a mermaid diagram
|
||||
const mermaidKeywords = [
|
||||
"graph ",
|
||||
"sequenceDiagram",
|
||||
@@ -172,10 +174,12 @@ export const Mermaid = Node.create({
|
||||
code.textContent = node.attrs.content || "";
|
||||
pre.appendChild(code);
|
||||
|
||||
// Validation status indicator
|
||||
const statusIndicator = document.createElement("div");
|
||||
statusIndicator.className =
|
||||
"absolute top-2 left-2 w-3 h-3 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200";
|
||||
|
||||
// Validate syntax asynchronously
|
||||
const validateSyntax = async () => {
|
||||
const content = node.attrs.content || "";
|
||||
if (!content.trim()) {
|
||||
@@ -246,6 +250,7 @@ export const Mermaid = Node.create({
|
||||
(p: any) => p.spec?.key === "mermaidSelection"
|
||||
);
|
||||
|
||||
// Use intersection observer to trigger update when visible
|
||||
let updateInterval: ReturnType<typeof setInterval> | null = null;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
|
||||
29
src/components/icons/BackArrow.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
const BackArrow = (props: {
|
||||
height: number;
|
||||
width: number;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
class?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div class={props.class}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={props.strokeWidth}
|
||||
stroke={props.stroke}
|
||||
height={props.height}
|
||||
width={props.width}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackArrow;
|
||||
39
src/components/icons/MenuBars.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
function MenuBars() {
|
||||
return (
|
||||
<svg
|
||||
width="36"
|
||||
height="30"
|
||||
viewBox="0 0 120 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g id="Mask group">
|
||||
<g id="Frame 1">
|
||||
<rect width="120" height="100" />
|
||||
<line
|
||||
id="LineA"
|
||||
x1="11.5"
|
||||
y1="31.5"
|
||||
x2="108.5"
|
||||
y2="31.5"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
class="stroke-black dark:stroke-white"
|
||||
/>
|
||||
<line
|
||||
id="LineB"
|
||||
x1="11.5"
|
||||
y1="64.5"
|
||||
x2="108.5"
|
||||
y2="64.5"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
class="stroke-black dark:stroke-white"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default MenuBars;
|
||||
@@ -1,367 +0,0 @@
|
||||
import { For, type JSX } from "solid-js";
|
||||
|
||||
/**
|
||||
* Pixel-art campfire sprite, faithful to the app's CampfireFrames: a 14×14
|
||||
* grid where the log base never moves and only the flame and sparks animate.
|
||||
* `state` picks the frame set and cadence — idle simmers, running dances,
|
||||
* ready exhales, error pulses dead embers.
|
||||
*/
|
||||
|
||||
const FIRE_PALETTE: Record<string, string> = {
|
||||
o: "#e7873a",
|
||||
y: "#ffce70",
|
||||
w: "#ffffff",
|
||||
b: "#8b5a2b",
|
||||
d: "#5a3a1b",
|
||||
s: "#ffd166",
|
||||
r: "#c43a30",
|
||||
R: "#f25240",
|
||||
x: "#ff7864",
|
||||
m: "rgba(255,255,255,0.5)",
|
||||
k: "rgba(255,255,255,0.26)",
|
||||
e: "#a8582a",
|
||||
E: "#d87636",
|
||||
B: "#4890fc",
|
||||
L: "#92c7ff",
|
||||
C: "#e9f8ff",
|
||||
S: "#bfe0ff"
|
||||
};
|
||||
|
||||
interface CampfireFrame {
|
||||
rows: string[];
|
||||
}
|
||||
|
||||
const IDLE_FRAMES: CampfireFrame[] = [
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
".....oyo......",
|
||||
"....oyyyo.....",
|
||||
"..dddeeeeeEd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
"..............",
|
||||
".........k....",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"......oyo.....",
|
||||
"....oyyyo.....",
|
||||
"..dddEEEEEEd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const RUN_FRAMES: CampfireFrame[] = [
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
"..............",
|
||||
".....s.yy.....",
|
||||
".....oyyo.....",
|
||||
"....oywwyo....",
|
||||
"...oyywwyyo...",
|
||||
"...oyywwyyo...",
|
||||
"...oyywwyyo...",
|
||||
"....oywwyo....",
|
||||
"..dddddddddd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
".....s........",
|
||||
".....yy.......",
|
||||
".....oyyo.....",
|
||||
"....oywwyo....",
|
||||
"...oyywwyyo...",
|
||||
"...oyywwyyo...",
|
||||
"...oyywwyyo...",
|
||||
"....oywwyo....",
|
||||
"..dddddddddd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const READY_FRAMES: CampfireFrame[] = [
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"......yy......",
|
||||
".....oyyo.....",
|
||||
"....oyyyyo....",
|
||||
"....oyyyyo....",
|
||||
"..dddyyyyyyd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"........m.....",
|
||||
".....yy.......",
|
||||
".....oyyo.....",
|
||||
"....oyyyyo....",
|
||||
"....oyyyyo....",
|
||||
"..dddyyyyyyd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/* App CampfireFrames.ques0-5: cool blue flame, tip dips, sparks spit. */
|
||||
const QUES_FRAMES: CampfireFrame[] = [
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
"..............",
|
||||
".....S.LL.....",
|
||||
".....BLLB.....",
|
||||
"....BLCCLB....",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"....BLCCLB....",
|
||||
"..dddccccccd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
".....S........",
|
||||
".....LL.......",
|
||||
".....BLLB.....",
|
||||
"....BLCCLB....",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"....BLCCLB....",
|
||||
"..dddccccccd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
".....S........",
|
||||
"......LL......",
|
||||
".....BLLB.....",
|
||||
"....BLCCLB....",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"....BLCCLB....",
|
||||
"..dddccccccd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
".....S........",
|
||||
"..............",
|
||||
"......LL......",
|
||||
".....BLLB.....",
|
||||
"....BLCCLB....",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"....BLCCLB....",
|
||||
"..dddccccccd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
".....S........",
|
||||
"..............",
|
||||
".......LL.....",
|
||||
".....BLLB.....",
|
||||
"....BLCCLB....",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"....BLCCLB....",
|
||||
"..dddccccccd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
"........S.....",
|
||||
"..............",
|
||||
".......LL.....",
|
||||
".....BLLB.....",
|
||||
"....BLCCLB....",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"...BLLCCLLB...",
|
||||
"....BLCCLB....",
|
||||
"..dddccccccd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const ERROR_FRAMES: CampfireFrame[] = [
|
||||
{
|
||||
rows: [
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..dddrrrrrrd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [
|
||||
"........x.....",
|
||||
"..............",
|
||||
".....x........",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..............",
|
||||
"..dddRRRRRRd..",
|
||||
"..dbbbbbbbbd..",
|
||||
"..............",
|
||||
"..............",
|
||||
".............."
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export type CampfireState = "idle" | "running" | "ready" | "question" | "error";
|
||||
|
||||
const STATE_FRAMES: Record<CampfireState, CampfireFrame[]> = {
|
||||
idle: IDLE_FRAMES,
|
||||
running: RUN_FRAMES,
|
||||
ready: READY_FRAMES,
|
||||
question: QUES_FRAMES,
|
||||
error: ERROR_FRAMES
|
||||
};
|
||||
|
||||
const STATE_FPS: Record<CampfireState, number> = {
|
||||
idle: 2,
|
||||
running: 9,
|
||||
ready: 4,
|
||||
question: 4.5,
|
||||
error: 4
|
||||
};
|
||||
|
||||
export function Campfire(props: { state: CampfireState; pixel?: number }) {
|
||||
const pixel = () => props.pixel ?? 3;
|
||||
return (
|
||||
<div
|
||||
class="animate-campfire grid"
|
||||
style={{
|
||||
"grid-template-columns": `repeat(14, ${pixel()}px)`,
|
||||
/* One full cycle: every frame exactly one frame-interval long. */
|
||||
"--campfire-duration": `${(1000 * STATE_FRAMES[props.state].length) / STATE_FPS[props.state] / 1000}s`
|
||||
}}
|
||||
>
|
||||
{/* Slot phase lives in the keyframes per layer (campfire-slot-N-x);
|
||||
delay-offset layers blank a wrap frame under WebKit. */}
|
||||
<For each={STATE_FRAMES[props.state]}>
|
||||
{(frame, i) => {
|
||||
const n = STATE_FRAMES[props.state].length;
|
||||
const phase = String.fromCharCode(97 + (i() % 26));
|
||||
return (
|
||||
<div
|
||||
class="campfire-frame col-span-full row-start-1"
|
||||
style={{ "--slot-kf": `campfire-slot-${n}-${phase}` }}
|
||||
>
|
||||
<For each={frame.rows}>
|
||||
{(row) => (
|
||||
<div class="flex" style={{ height: `${pixel()}px` }}>
|
||||
<For each={row.split("")}>
|
||||
{(ch) => (
|
||||
<span
|
||||
class="inline-block"
|
||||
style={{
|
||||
width: `${pixel()}px`,
|
||||
height: `${pixel()}px`,
|
||||
background:
|
||||
ch === "." ? "transparent" : FIRE_PALETTE[ch]
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,714 +0,0 @@
|
||||
import {
|
||||
For,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type JSX
|
||||
} from "solid-js";
|
||||
import {
|
||||
IslandPill,
|
||||
AgentDotGrid,
|
||||
CALM,
|
||||
ACCENT,
|
||||
CRITICAL,
|
||||
ModuleCard,
|
||||
FakeCursor
|
||||
} from "./IslandMock";
|
||||
import { Campfire, type CampfireState } from "./Campfire";
|
||||
import {
|
||||
ApprovalCardMock,
|
||||
SessionRowMock,
|
||||
CLAUDE,
|
||||
CODEX
|
||||
} from "./ModuleMocks";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { BatteryDemo, CalendarDemo, MixerDemo } from "./UtilityDemos";
|
||||
import {
|
||||
TerminalReplyDemo,
|
||||
RemoteHostDemo,
|
||||
CaptureDemo,
|
||||
FloatingActionsDemo
|
||||
} from "./MoreDemos";
|
||||
|
||||
/**
|
||||
* Detailed feature breakdowns: alternating copy + live-demo sections.
|
||||
* Each demo stages the real interaction loop: attention flares, approval
|
||||
* resolves, fans throttle, the charge ceiling holds, meetings surface,
|
||||
* the camera wakes. Newer demos live in
|
||||
* MoreDemos.tsx; app-anatomy pieces in ModuleMocks.tsx.
|
||||
*/
|
||||
|
||||
/* Scroll-triggered reveal: adds `in` once the element crosses 25%. */
|
||||
function useReveal() {
|
||||
const [el, setEl] = createSignal<HTMLDivElement>();
|
||||
onMount(() => {
|
||||
const node = el();
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
node.classList.add("in");
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.25 }
|
||||
);
|
||||
observer.observe(node);
|
||||
onCleanup(() => observer.disconnect());
|
||||
});
|
||||
return [setEl, el] as const;
|
||||
}
|
||||
|
||||
function BreakdownSection(props: {
|
||||
kicker: string;
|
||||
title: string;
|
||||
body: string;
|
||||
bullets: string[];
|
||||
reversed?: boolean;
|
||||
children: JSX.Element;
|
||||
}) {
|
||||
const [ref] = useReveal();
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
class="reveal grid grid-cols-1 items-center gap-10 md:grid-cols-2"
|
||||
>
|
||||
<div class={props.reversed ? "md:order-2" : ""}>
|
||||
<p class="text-subtext1 mb-2 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||
{props.kicker}
|
||||
</p>
|
||||
<h3 class="text-text mb-3 text-2xl font-bold tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p class="text-subtext0 mb-5 leading-relaxed">{props.body}</p>
|
||||
<ul class="space-y-2.5">
|
||||
<For each={props.bullets}>
|
||||
{(bullet) => (
|
||||
<li class="text-subtext1 flex gap-2.5 text-sm">
|
||||
<span
|
||||
class="mt-[7px] inline-block h-1.5 w-1.5 shrink-0 rounded-[2px]"
|
||||
style={{ background: ACCENT }}
|
||||
/>
|
||||
{bullet}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
class={`rounded-2xl p-6 md:p-8 ${props.reversed ? "md:order-1" : ""}`}
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(70% 90% at 50% 0%, rgba(72,151,178,0.12) 0%, transparent 70%) #0d0d0f"
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 1: the fleet loop — attention flares, then resolves ─────── */
|
||||
|
||||
function FleetDemo() {
|
||||
const [phase, setPhase] = createSignal<"watch" | "flare" | "resolve">(
|
||||
"watch"
|
||||
);
|
||||
onMount(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const loop = () => {
|
||||
setPhase("watch");
|
||||
timers.push(setTimeout(() => setPhase("flare"), 3200));
|
||||
timers.push(setTimeout(() => setPhase("resolve"), 6400));
|
||||
};
|
||||
loop();
|
||||
const interval = setInterval(loop, 9600);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
timers.forEach(clearTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
const states = () => {
|
||||
switch (phase()) {
|
||||
case "watch":
|
||||
return ["running", "running", "ready", "running", "idle"] as const;
|
||||
case "flare":
|
||||
return ["running", "attention", "ready", "running", "idle"] as const;
|
||||
case "resolve":
|
||||
return ["running", "ready", "ready", "running", "idle"] as const;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<IslandPill
|
||||
campfire={phase() === "flare" ? "question" : "running"}
|
||||
right={<AgentDotGrid states={states().slice(0, 4) as never} />}
|
||||
/>
|
||||
<div
|
||||
class="rounded-xl px-4 py-3 text-center shadow-lg transition-all duration-500"
|
||||
style={{
|
||||
background:
|
||||
phase() === "flare"
|
||||
? "rgba(231,167,98,0.12)"
|
||||
: "rgba(255,255,255,0.055)",
|
||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.07)",
|
||||
transform:
|
||||
phase() === "flare"
|
||||
? "translateY(0) scale(1)"
|
||||
: "translateY(6px) scale(0.98)",
|
||||
opacity: phase() === "watch" ? 0.55 : 1
|
||||
}}
|
||||
>
|
||||
<p class="text-[13px] font-semibold text-white">
|
||||
{phase() === "flare"
|
||||
? "Codex needs your input"
|
||||
: phase() === "resolve"
|
||||
? "Reply sent. Agents running."
|
||||
: "Agents running"}
|
||||
</p>
|
||||
<p class="mt-0.5 text-[11px] text-white/50">
|
||||
{phase() === "flare" ? "Question waiting in the island" : "\u00a0"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 2: an approval surfaces the island; Allow resumes the run ── */
|
||||
|
||||
function ApprovalDemo() {
|
||||
const [phase, setPhase] = createSignal<"calm" | "ask" | "press" | "resumed">("calm");
|
||||
useDemoLoop(10400, [
|
||||
[0, () => setPhase("calm")],
|
||||
[1400, () => setPhase("ask")],
|
||||
[4600, () => setPhase("press")],
|
||||
[4950, () => setPhase("resumed")],
|
||||
[8800, () => setPhase("calm")]
|
||||
]);
|
||||
const asking = () => phase() === "ask" || phase() === "press";
|
||||
const open = () => phase() !== "calm";
|
||||
return (
|
||||
<div class="relative flex h-[390px] flex-col items-center gap-3">
|
||||
<IslandPill
|
||||
campfire={asking() ? "question" : "running"}
|
||||
right={
|
||||
<AgentDotGrid
|
||||
states={asking() ? ["attention", "running", "ready"] : ["running", "running", "ready"]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* the island opens: approval pinned above the session rows */}
|
||||
<div
|
||||
class="w-full max-w-[380px] overflow-hidden rounded-[14px] bg-black transition-all duration-700"
|
||||
style={{
|
||||
"max-height": open() ? "320px" : "0px",
|
||||
opacity: open() ? 1 : 0,
|
||||
transform: open() ? "translateY(0)" : "translateY(-8px)"
|
||||
}}
|
||||
>
|
||||
<div class="p-2.5">
|
||||
<div
|
||||
class="grid transition-all duration-500"
|
||||
style={{ "grid-template-rows": asking() ? "1fr" : "0fr", opacity: asking() ? 1 : 0 }}
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div class="pb-1.5">
|
||||
<ApprovalCardMock
|
||||
agent={CLAUDE}
|
||||
title="the-nook · harden the bridge"
|
||||
tool="Bash"
|
||||
input="swift test --filter Bridge"
|
||||
pressed={phase() === "press" ? "allow" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<SessionRowMock
|
||||
title="the-nook · main"
|
||||
prompt="harden the bridge"
|
||||
state={
|
||||
asking()
|
||||
? "Approve Bash: swift test --filter Bridge?"
|
||||
: "Running swift test --filter Bridge…"
|
||||
}
|
||||
status={asking() ? "attention" : "running"}
|
||||
agent={CLAUDE}
|
||||
origin="iTerm2"
|
||||
age="6m"
|
||||
/>
|
||||
<SessionRowMock
|
||||
title="site · nook-334"
|
||||
prompt="match the mocks to the app"
|
||||
state="Reading ModuleMocks.tsx…"
|
||||
status="running"
|
||||
agent={CODEX}
|
||||
origin="kitty-tmux"
|
||||
age="2m"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-[380px] -translate-x-1/2">
|
||||
<FakeCursor
|
||||
phase={phase() === "press" ? "press" : asking() ? "idle" : "live"}
|
||||
at={{ x: 9.5, y: 41 }}
|
||||
exit={{ x: 30, y: 70 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 3: fans throttle up under load, then back down ──────────── */
|
||||
|
||||
/* Spark pool: deterministic pseudo-random, count gated by fan speed. */
|
||||
const sparkRand = (i: number, k: number) => {
|
||||
const x = Math.sin(i * 127.1 + k * 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
const SPARKS = Array.from({ length: 20 }, (_, i) => ({
|
||||
sx: `${Math.round(10 + sparkRand(i, 1) * 26)}px`,
|
||||
sy: `${Math.round(18 + sparkRand(i, 2) * 30)}px`,
|
||||
sd: `${(0.4 + sparkRand(i, 3) * 0.25).toFixed(2)}s`,
|
||||
sdel: `${(sparkRand(i, 4) * 1.2).toFixed(2)}s`,
|
||||
size: sparkRand(i, 5) > 0.5 ? 3 : 2
|
||||
}));
|
||||
|
||||
function FansDemo() {
|
||||
const [rpm, setRpm] = createSignal(1270);
|
||||
const [maxed, setMaxed] = createSignal(false);
|
||||
onMount(() => {
|
||||
let frame = 0;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const ramp = (target: number, durationMs: number) => {
|
||||
cancelAnimationFrame(frame);
|
||||
const start = rpm();
|
||||
const startTime = performance.now();
|
||||
const step = (now: number) => {
|
||||
const t = Math.min((now - startTime) / durationMs, 1);
|
||||
setRpm(Math.round(start + (target - start) * t));
|
||||
if (t < 1) frame = requestAnimationFrame(step);
|
||||
};
|
||||
frame = requestAnimationFrame(step);
|
||||
};
|
||||
const loop = () => {
|
||||
setMaxed(true);
|
||||
ramp(5960, 1200);
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
setMaxed(false);
|
||||
ramp(1270, 1600);
|
||||
}, 5200)
|
||||
);
|
||||
};
|
||||
loop();
|
||||
const interval = setInterval(loop, 10400);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
timers.forEach(clearTimeout);
|
||||
cancelAnimationFrame(frame);
|
||||
});
|
||||
});
|
||||
|
||||
const fans = () => [
|
||||
{ rpm: rpm(), frac: rpm() / 6000 },
|
||||
{ rpm: Math.round(rpm() * 0.96), frac: (rpm() * 0.96) / 6000 }
|
||||
];
|
||||
|
||||
const shakeIntensity = () =>
|
||||
Math.max(0, Math.min(1, (rpm() - 2500) / (6000 - 2500)));
|
||||
/* 5 sparks when the shake starts, ~20 at max speed. */
|
||||
const sparkCount = () =>
|
||||
shakeIntensity() > 0 ? 5 + Math.floor(shakeIntensity() * 15) : 0;
|
||||
return (
|
||||
<div class="relative mx-auto w-full max-w-[320px]">
|
||||
<ModuleCard
|
||||
class="fan-card-shake relative z-10"
|
||||
style={{
|
||||
"--fan-amp": `${(shakeIntensity() * 2.6).toFixed(2)}deg`,
|
||||
"--fan-speed": `${(0.4 - shakeIntensity() * 0.28).toFixed(3)}s`
|
||||
}}
|
||||
>
|
||||
<div class="space-y-3 text-left">
|
||||
<For each={fans()}>
|
||||
{(f, i) => (
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[10px] font-medium text-white/50">
|
||||
Fan {i() + 1}
|
||||
</span>
|
||||
<div
|
||||
class="h-1 flex-1 overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(f.frac, 1) * 100}%`,
|
||||
background: maxed() ? CRITICAL : CALM,
|
||||
transition: "width 0.2s linear, background 0.4s"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="font-mono text-white"
|
||||
style={{ "font-size": "13px", "font-weight": 500 }}
|
||||
>
|
||||
{Math.round(f.rpm)}
|
||||
</span>
|
||||
<span class="text-[9px] font-semibold text-white/50">RPM</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-md px-2 py-0.5 text-[11px] font-bold transition-colors"
|
||||
style={{ color: maxed() ? CRITICAL : "rgba(255,255,255,0.7)" }}
|
||||
>
|
||||
{maxed() ? "MAX" : "Auto"}
|
||||
</span>
|
||||
<span class="text-[10px] text-white/40">
|
||||
{maxed() ? "cooling session active" : "curve in control"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
<div
|
||||
class="pointer-events-none absolute z-0"
|
||||
style={{
|
||||
right: "6px",
|
||||
bottom: "-2px",
|
||||
opacity: sparkCount() > 0 ? "1" : "0",
|
||||
transition: "opacity 0.3s"
|
||||
}}
|
||||
>
|
||||
<For each={SPARKS.slice(0, sparkCount())}>
|
||||
{(s) => (
|
||||
<span
|
||||
class="fan-spark"
|
||||
style={{
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
"--sx": s.sx,
|
||||
"--sy": s.sy,
|
||||
"--sd": s.sd,
|
||||
"--sdel": s.sdel
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 6: the lens only wakes for a click ──────────────── */
|
||||
|
||||
function CameraDemo() {
|
||||
const [phase, setPhase] = createSignal<"idle" | "press" | "live">("idle");
|
||||
let videoRef: HTMLVideoElement | undefined;
|
||||
onMount(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const loop = () => {
|
||||
setPhase("idle");
|
||||
timers.push(setTimeout(() => setPhase("press"), 2600));
|
||||
timers.push(setTimeout(() => setPhase("live"), 3000));
|
||||
};
|
||||
loop();
|
||||
const interval = setInterval(loop, 8800);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
timers.forEach(clearTimeout);
|
||||
});
|
||||
});
|
||||
createEffect(() => {
|
||||
if (phase() === "live" && videoRef) {
|
||||
videoRef.currentTime = 0;
|
||||
// play() rejects with AbortError when interrupted by pause() (the loop
|
||||
// resetting to "idle") or when the browser pauses background media to
|
||||
// save power. Swallow it — the rejection is expected, not a failure.
|
||||
void videoRef.play().catch(() => {});
|
||||
} else {
|
||||
videoRef?.pause();
|
||||
}
|
||||
});
|
||||
return (
|
||||
<ModuleCard class="relative mx-auto w-full max-w-[300px]">
|
||||
<div class="space-y-2.5 text-left">
|
||||
<div class="flex items-center">
|
||||
<span class="text-[11px] font-semibold text-white/50">Camera</span>
|
||||
<span
|
||||
class="ml-auto rounded-md px-2 py-0.5 text-[10px] text-white/60 transition-opacity duration-300"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
opacity: phase() === "live" ? 1 : 0
|
||||
}}
|
||||
>
|
||||
FaceTime HD Camera ▾
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative h-[120px] overflow-hidden rounded-lg">
|
||||
{/* inert dashed start button */}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 transition-all duration-500"
|
||||
style={{
|
||||
border: "1px dashed rgba(255,255,255,0.14)",
|
||||
"border-radius": "8px",
|
||||
opacity: phase() === "live" ? 0 : 1,
|
||||
transform: phase() === "press" ? "scale(0.96)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="14" viewBox="0 0 18 14">
|
||||
<path
|
||||
d="M2 2.8A1.8 1.8 0 0 1 3.8 1h2.2l1 1.4h4.2A1.8 1.8 0 0 1 13 4.2v6.6a1.8 1.8 0 0 1-1.8 1.8H3.8A1.8 1.8 0 0 1 2 10.8z"
|
||||
fill="rgba(255,255,255,0.5)"
|
||||
/>
|
||||
<circle cx="7.5" cy="7.4" r="2.6" fill="rgba(0,0,0,0.35)" />
|
||||
</svg>
|
||||
<span class="text-[12px] font-semibold text-white/80">
|
||||
Start camera
|
||||
</span>
|
||||
</div>
|
||||
{/* live preview */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src="/nook/cam-recording.mp4"
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onEnded={() => setPhase("idle")}
|
||||
class="absolute inset-0 h-full w-full object-cover transition-opacity duration-700"
|
||||
style={{ opacity: phase() === "live" ? 1 : 0 }}
|
||||
/>
|
||||
<FakeCursor phase={phase()} at={{ x: 50, y: 50 }} />
|
||||
</div>
|
||||
<p class="text-[10px] text-white/40">
|
||||
{phase() === "live"
|
||||
? "Camera on while this panel is open"
|
||||
: "Camera off until you click Start"}
|
||||
</p>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Demo 8: campfire states ───────────────────────────────────────── */
|
||||
|
||||
function CampfireDemo() {
|
||||
const states: CampfireState[] = [
|
||||
"running",
|
||||
"question",
|
||||
"ready",
|
||||
"idle",
|
||||
"error"
|
||||
];
|
||||
const labels = [
|
||||
"Running",
|
||||
"Question",
|
||||
"Ready",
|
||||
"Idle",
|
||||
"Error"
|
||||
];
|
||||
return (
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
<For each={states}>
|
||||
{(state, i) => (
|
||||
<div class="flex flex-col items-center gap-2.5">
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-xl"
|
||||
style={{ background: "rgba(255,255,255,0.055)" }}
|
||||
>
|
||||
<Campfire state={state} pixel={3} />
|
||||
</div>
|
||||
<p class="text-center text-[11px] font-medium text-white/60">
|
||||
{labels[i()]}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FeatureBreakdowns() {
|
||||
return (
|
||||
<div class="space-y-24">
|
||||
<BreakdownSection
|
||||
kicker="The island"
|
||||
title="See which agents need your attention"
|
||||
body="Each square represents an agent session. Its color shows whether the agent is working, finished, or waiting for you. Questions appear in the island so you can respond without switching windows."
|
||||
bullets={[
|
||||
"18 agents: Claude Code, Codex, Gemini CLI, OpenCode, Pi, Muse, and more",
|
||||
"Connect custom agents using the published integration contract",
|
||||
"Choose which information appears in the compact view",
|
||||
"Works on displays without a notch, centered at the top of the screen"
|
||||
]}
|
||||
>
|
||||
<FleetDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Approvals"
|
||||
title="Approve agent requests"
|
||||
body="When an agent needs permission, the island opens with a request card above your sessions. Review the tool and its input, then allow or deny it. You can include a reason when you deny a request."
|
||||
bullets={[
|
||||
"Review permissions above the session list and answer questions in the session row",
|
||||
"Allow for session approves subsequent requests in that session",
|
||||
"Respond from iPhone or Apple Watch with the TestFlight beta",
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<ApprovalDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Terminals"
|
||||
title="Reply to agents and open their terminals"
|
||||
body="Send a reply from the island to the terminal pane running that session. Use Jump to bring its window forward when you want to work in the terminal."
|
||||
bullets={[
|
||||
"Replies reach agents in tmux, iTerm2, Terminal, and kitty",
|
||||
"Jump focuses the exact window, even for sessions over SSH",
|
||||
"Per-turn replies render as Markdown, with tables and code"
|
||||
]}
|
||||
>
|
||||
<TerminalReplyDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Remote"
|
||||
title="Set up remote agents in one click"
|
||||
body="Choose a Tailscale peer or an authorized host from your SSH config and click Set up. The Nook installs its integrations and checks that the host can reach your Mac."
|
||||
bullets={[
|
||||
"Local and remote sessions in the same island, labelled by host",
|
||||
"Remote integrations follow the agents installed on this Mac",
|
||||
"Direct connections over Tailscale, or an SSH tunnel for other hosts",
|
||||
"Uses the host\'s existing SSH authorization"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<RemoteHostDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="The campfire"
|
||||
title="Agent status in the campfire"
|
||||
body="The pixel-art campfire changes with your agents' activity. Different animations indicate work in progress, a question waiting for you, or an error."
|
||||
bullets={[
|
||||
"Separate animations for idle, running, ready, question, and error states",
|
||||
"Hand-drawn 14×14 sprites, no tweening",
|
||||
"Six chiptunes you can assign to any agent event"
|
||||
]}
|
||||
>
|
||||
<CampfireDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Cooling"
|
||||
title="Monitor and control your Mac's fans"
|
||||
body="Check each fan's speed and choose how cooling responds to load. Keep the stock fan curve, use Aggressive for earlier cooling, or start a MAX session to run the fans at full speed."
|
||||
bullets={[
|
||||
"Switch between Stock, Aggressive, and MAX",
|
||||
"See active MAX and COOL sessions in the compact view",
|
||||
"CPU and GPU temperature readings change color as they rise",
|
||||
"Hardware monitoring runs locally on your Mac"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<FansDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Power"
|
||||
title="Set a battery charge limit"
|
||||
body="Set the charge limit on the battery bar, or enter a percentage. The power diagram shows how much power goes to your Mac and battery. Turn on Top-up mode when you need a full charge."
|
||||
bullets={[
|
||||
"Drag the limit marker or type a percentage; available limits depend on your Mac",
|
||||
"Uses macOS charge limits where available, with helper-based control on supported Macs",
|
||||
"Top-up mode allows charging past the limit and turns off when you unplug",
|
||||
"Overheat protection can pause charging when the battery gets hot",
|
||||
"Separate readings for system draw and battery charging, not a wall-power measurement"
|
||||
]}
|
||||
>
|
||||
<BatteryDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Sound"
|
||||
title="Adjust volume and output for each app"
|
||||
body="Set an app's volume without changing the others, mute it, or choose a different output. You can play music through headphones and keep a meeting on the speakers."
|
||||
bullets={[
|
||||
"Per-app volume, mute, and output routing",
|
||||
"Master output, system sounds, and microphone pickers in one panel",
|
||||
"Includes audio from command-line players"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<MixerDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Capture"
|
||||
title="Take screenshots and record your screen"
|
||||
body="Capture an area, a whole screen, or one app. During a recording, controls beside the island let you pause, stop, and toggle system audio or your microphone."
|
||||
bullets={[
|
||||
"Area, screen, or single-app capture",
|
||||
"Screenshots save to your screenshot folder and copy to the clipboard",
|
||||
"System audio and microphone tracks for recordings",
|
||||
"The Nook's own windows never show up in a capture"
|
||||
]}
|
||||
>
|
||||
<CaptureDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Your day"
|
||||
title="Check your calendar and join meetings"
|
||||
body="Browse your calendar from the day strip and open a meeting from its event row. The compact view shows today's date, a countdown before an event, and an ON AIR indicator while a meeting is in progress."
|
||||
bullets={[
|
||||
"Date, countdown, and meeting progress in the compact view",
|
||||
"Choose how early the island opens before a meeting",
|
||||
"Join meetings from the video button beside an event",
|
||||
"Uses your Mac's calendars and reminders"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<CalendarDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Camera"
|
||||
title="Preview your camera before a call"
|
||||
body="Check your camera in the island before joining a call. The preview starts only when you click Start camera and stops when you leave the panel."
|
||||
bullets={[
|
||||
"Requires camera permission and a click on Start camera",
|
||||
"Preview runs only while the panel is open",
|
||||
"A device picker for every connected camera",
|
||||
"Add the camera module to a panel when you want it"
|
||||
]}
|
||||
>
|
||||
<CameraDemo />
|
||||
</BreakdownSection>
|
||||
|
||||
<BreakdownSection
|
||||
kicker="Floating actions"
|
||||
title="Add shortcuts beside the island"
|
||||
body="Add up to seven actions beside the expanded island. Use them to open apps, run shortcuts, or change settings without opening a panel."
|
||||
bullets={[
|
||||
"Run an Apple Shortcut, open an app or URL, or run a shell command",
|
||||
"Toggle Low Power Mode, top-up, or overheat protection",
|
||||
"Set the charge limit, cycle audio outputs, or start a timer",
|
||||
"Cycle the approval mode without opening Settings"
|
||||
]}
|
||||
reversed
|
||||
>
|
||||
<FloatingActionsDemo />
|
||||
</BreakdownSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js";
|
||||
import { IslandPill, STATUS, CALM, CRITICAL, ModuleCard } from "./IslandMock";
|
||||
import { ApprovalCardMock, SessionRowMock, DieLine, CLAUDE, CODEX } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Feature collage: five cells sold as the app's actual UI. The hero cell
|
||||
* shows the real collapsed pill (campfire slot, camera housing, live data)
|
||||
* dressed in the island's black surface; supporting cells reuse the same
|
||||
* module chrome, status palette, and metric language.
|
||||
*/
|
||||
|
||||
export function CollageCell(props: {
|
||||
class?: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
body: string;
|
||||
children?: JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={`border-overlay1 bg-surface1 relative flex flex-col overflow-hidden rounded-2xl border p-6 ${props.class ?? ""}`}
|
||||
>
|
||||
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||
{props.kicker}
|
||||
</p>
|
||||
<h3 class="text-text mb-2 text-lg font-bold tracking-tight">
|
||||
{props.title}
|
||||
</h3>
|
||||
<p class="text-subtext0 text-sm leading-relaxed">{props.body}</p>
|
||||
<div class="mt-5 flex-1">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage for app-UI mocks: the island's black ink ground. The real island
|
||||
* is always dark (preferredColorScheme .dark), so the mocks read correctly
|
||||
* in both site themes.
|
||||
*/
|
||||
export function InkStage(props: { children: JSX.Element; class?: string }) {
|
||||
return (
|
||||
<div
|
||||
class={`flex justify-center rounded-xl py-5 ${props.class ?? ""}`}
|
||||
style={{ background: "#0d0d0f" }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fans panel mock: RPM gauge rows in the app's card chrome. */
|
||||
function FansPanelMock() {
|
||||
const fans = [
|
||||
{ rpm: 1270, frac: 0.26 },
|
||||
{ rpm: 1219, frac: 0.25 }
|
||||
];
|
||||
return (
|
||||
<ModuleCard>
|
||||
<div class="space-y-2.5">
|
||||
<For each={fans}>
|
||||
{(f) => (
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="h-1 flex-1 overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={{
|
||||
width: `${f.frac * 100}%`,
|
||||
background: CALM
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="font-mono text-white"
|
||||
style={{ "font-size": "13px", "font-weight": 500 }}
|
||||
>
|
||||
{f.rpm}
|
||||
</span>
|
||||
<span class="text-[9px] font-semibold text-white/50">RPM</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||
<div class="flex gap-2">
|
||||
<span class="rounded-md border border-white/15 px-2 py-0.5 text-[11px] text-white/70">
|
||||
Auto
|
||||
</span>
|
||||
<span
|
||||
class="rounded-md px-2 py-0.5 text-[11px] font-bold"
|
||||
style={{ color: CRITICAL }}
|
||||
>
|
||||
MAX
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoteMock() {
|
||||
return (
|
||||
<div class="space-y-1.5">
|
||||
<SessionRowMock
|
||||
title="api · main"
|
||||
prompt="fix the flaky test"
|
||||
state="Running pytest -x…"
|
||||
status="running"
|
||||
agent={CLAUDE}
|
||||
host="build-box"
|
||||
age="4m"
|
||||
/>
|
||||
<SessionRowMock
|
||||
title="trainer · fp16"
|
||||
prompt="benchmark fp16"
|
||||
state="Done · replied 1m ago"
|
||||
status="ready"
|
||||
agent={CODEX}
|
||||
host="gpu-rig"
|
||||
age="12m"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Deterministic usage wander for the traces. */
|
||||
const wander = (n: number, k: number) =>
|
||||
0.42 + 0.22 * Math.sin(n * 0.45 + k * 1.7) + 0.12 * Math.sin(n * 1.3 + k);
|
||||
|
||||
/**
|
||||
* The Temperatures module: one line per die — label, its 60 s usage trace,
|
||||
* its temperature. The GPU climbs past the 70 ° warn line and tints amber.
|
||||
*/
|
||||
function GaugesMock() {
|
||||
const [n, setN] = createSignal(0);
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => setN((v) => v + 1), 700);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
const trace = (k: number) =>
|
||||
Array.from({ length: 30 }, (_, i) => Math.max(0.04, Math.min(0.97, wander(n() + i, k))));
|
||||
const gpuCelsius = () => 62 + 12 * (0.5 + 0.5 * Math.sin(n() * 0.18));
|
||||
return (
|
||||
<ModuleCard>
|
||||
<div class="space-y-2">
|
||||
<DieLine label="CPU" usage={trace(0)} celsius={58 + 4 * Math.sin(n() * 0.3)} />
|
||||
<DieLine label="GPU" usage={trace(2)} celsius={gpuCelsius()} />
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FeatureCollage() {
|
||||
return (
|
||||
<section
|
||||
class="bg-base relative z-20 px-4 pt-40 pb-16 md:px-8"
|
||||
id="feature-collage"
|
||||
>
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
|
||||
Why the Nook
|
||||
</p>
|
||||
<h2 class="text-text mb-12 text-center text-3xl font-bold">
|
||||
Keep track of your agents while you work
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-3">
|
||||
{/* Hero: real collapsed pill on the island surface */}
|
||||
<div class="border-overlay1 bg-surface1 relative col-span-1 flex flex-col items-center overflow-hidden rounded-2xl border p-6 md:col-span-2">
|
||||
<div class="mb-5 self-start">
|
||||
<p class="text-subtext1 mb-3 text-[11px] font-semibold tracking-[0.14em] uppercase">
|
||||
The island
|
||||
</p>
|
||||
<h3 class="text-text mb-2 text-2xl font-bold tracking-tight">
|
||||
Agent status at the top of your screen
|
||||
</h3>
|
||||
<p class="text-subtext0 max-w-sm text-sm leading-relaxed">
|
||||
The island sits around your Mac's notch, with agent activity on
|
||||
the left and your choice of information on the right. Blue means
|
||||
running, green means ready, and amber means an agent needs you.
|
||||
</p>
|
||||
</div>
|
||||
<div class="my-10 flex w-full justify-center">
|
||||
<IslandPill
|
||||
campfire="running"
|
||||
right={
|
||||
<div class="flex flex-col items-center gap-1 px-1">
|
||||
<div class="flex gap-1.5">
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||
style={{ background: STATUS.running }}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||
style={{ background: STATUS.ready }}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||
style={{ background: STATUS.attention }}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||
style={{ background: STATUS.idle }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p class="text-subtext0 -mb-1 text-center text-xs">
|
||||
Open the island to see your sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Approvals */}
|
||||
<CollageCell
|
||||
kicker="Approvals"
|
||||
title="Review permission requests"
|
||||
body="Review permission requests above your sessions and choose Allow, Allow for session, or Deny. The iPhone and Apple Watch beta lets you respond away from your Mac."
|
||||
>
|
||||
<InkStage>
|
||||
<div class="w-full max-w-sm px-3">
|
||||
<ApprovalCardMock
|
||||
agent={CLAUDE}
|
||||
title="the-nook · bridge"
|
||||
tool="Edit"
|
||||
input="server/routes.ts"
|
||||
/>
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
{/* Fans */}
|
||||
<CollageCell
|
||||
kicker="Cooling"
|
||||
title="Fan speeds and cooling modes"
|
||||
body="Monitor each fan's speed and choose a cooling mode. Keep automatic control or start a MAX session before a demanding task."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full max-w-[260px] text-left">
|
||||
<FansPanelMock />
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
{/* Gauges */}
|
||||
<CollageCell
|
||||
kicker="Your Mac"
|
||||
title="CPU and GPU monitoring"
|
||||
body="See CPU and GPU temperatures beside their last 60 seconds of usage. Temperature readings turn amber at 70°C and red at 85°C."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full max-w-[300px] text-left">
|
||||
<GaugesMock />
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Remote"
|
||||
title="Agents on other machines"
|
||||
body="One-click setup for Tailscale and authorized known hosts. Remote sessions appear in the island with the host name beside the agent."
|
||||
>
|
||||
<InkStage class="w-full">
|
||||
<div class="w-full min-w-0 px-3" role="img" aria-label="Remote agent sessions on build-box and gpu-rig">
|
||||
<RemoteMock />
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
</div>
|
||||
|
||||
<p class="text-subtext1 mt-6 text-center text-xs">
|
||||
Also includes a volume mixer, screen capture, timers, weather, and more.{" "}
|
||||
<a
|
||||
href="#island-extras"
|
||||
class="underline decoration-dotted hover:opacity-80"
|
||||
>
|
||||
Browse the other modules
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,898 +0,0 @@
|
||||
import {
|
||||
For,
|
||||
Index,
|
||||
Show,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
onMount,
|
||||
type JSX
|
||||
} from "solid-js";
|
||||
import {
|
||||
ModuleCard,
|
||||
STATUS,
|
||||
ACCENT,
|
||||
CALM,
|
||||
WARM,
|
||||
CARD_FILL,
|
||||
CARD_STROKE
|
||||
} from "./IslandMock";
|
||||
import { CollageCell, InkStage } from "./FeatureCollage";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { Glyph, TimerTapeMock, TIMER_ORANGE, type GlyphName } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* "Everything else": a bento of small live tiles for the modules that do
|
||||
* not get a full breakdown, headed by a two-way marquee naming the rest.
|
||||
*/
|
||||
|
||||
/** Steady tick for tiles that animate continuously rather than in steps. */
|
||||
function useTick(everyMs: number) {
|
||||
const [tick, setTick] = createSignal(0);
|
||||
onMount(() => {
|
||||
const interval = setInterval(() => setTick((t) => t + 1), everyMs);
|
||||
onCleanup(() => clearInterval(interval));
|
||||
});
|
||||
return tick;
|
||||
}
|
||||
|
||||
/** A compact black island strip for tiles that show a slot reading. */
|
||||
function MiniPill(props: { children: JSX.Element; class?: string }) {
|
||||
return (
|
||||
<div
|
||||
class={`mx-auto flex h-[26px] items-center justify-center gap-2 rounded-b-[11px] bg-black px-3 ${props.class ?? ""}`}
|
||||
style={{ "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Now Playing + the waveform slot ─────────────────────────────── */
|
||||
|
||||
function NowPlayingMock() {
|
||||
return (
|
||||
<div class="flex w-full max-w-[420px] flex-col items-center gap-4 px-3 sm:flex-row sm:items-center">
|
||||
<ModuleCard class="w-full flex-1">
|
||||
<div class="flex items-center gap-3 text-left">
|
||||
<div
|
||||
class="nook-art h-12 w-12 shrink-0 rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
"conic-gradient(from 210deg, #fa5c7c, #6EA7FF, #6FB982, #E7A762, #fa5c7c)"
|
||||
}}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-[12px] font-semibold text-white">
|
||||
Night Drive
|
||||
</p>
|
||||
<p class="truncate text-[10px] text-white/50">
|
||||
Synthwave Radio · Browser
|
||||
</p>
|
||||
<div
|
||||
class="mt-2 h-[3px] overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.1)" }}
|
||||
>
|
||||
<div
|
||||
class="nook-progress h-full rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.75)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2.5 flex justify-center gap-5 text-[12px] text-white/70">
|
||||
<span>⏮</span>
|
||||
<span class="text-white">⏸</span>
|
||||
<span>⏭</span>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
<div class="flex flex-col items-center gap-1.5">
|
||||
<MiniPill>
|
||||
<span class="flex h-[14px] items-center gap-[2px]">
|
||||
<For each={[0, 1, 2, 3, 4, 5]}>
|
||||
{(i) => (
|
||||
<span
|
||||
class="nook-wave w-[2.5px] rounded-full"
|
||||
style={{
|
||||
background: "#fa5c7c",
|
||||
"animation-delay": `${i * -0.13}s`,
|
||||
"animation-duration": `${0.7 + (i % 3) * 0.18}s`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</span>
|
||||
</MiniPill>
|
||||
<span class="text-[9px] text-white/40">waveform slot</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Timers: the module's face — readout over the tick tape ──────── */
|
||||
|
||||
/** TimerService.format: m:ss, rounding up like the app. */
|
||||
function clock(seconds: number) {
|
||||
const s = Math.ceil(seconds);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** The face's capsule control: 26 px, mono 11, white 8 %. */
|
||||
function TimerPill(props: { glyph?: GlyphName; label?: string }) {
|
||||
return (
|
||||
<span class="flex h-[26px] flex-1 items-center justify-center gap-1 rounded-full bg-white/[0.08] font-mono text-[11px] font-medium whitespace-nowrap text-white">
|
||||
<Show when={props.glyph}>
|
||||
<Glyph name={props.glyph!} size={10} />
|
||||
</Show>
|
||||
{props.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* A 3:00 lead timer drains (in fast-forward), rings with its tape pulsing,
|
||||
* and a 25:00 Focus timer keeps running in the stacked row beneath.
|
||||
*/
|
||||
const LEAD_SECONDS = 180;
|
||||
const LEAD_MS = 9000;
|
||||
const TIMER_PERIOD = 14000;
|
||||
|
||||
function TimersMock() {
|
||||
const tick = useTick(50);
|
||||
const t = () => (tick() * 50) % TIMER_PERIOD;
|
||||
const leadLeft = () => Math.max(0, 1 - t() / LEAD_MS);
|
||||
const ringing = () => t() >= LEAD_MS;
|
||||
const focusLeft = () => 0.64 - (t() / TIMER_PERIOD) * 0.06;
|
||||
return (
|
||||
<ModuleCard class="w-full max-w-[260px]">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="flex h-[34px] items-center">
|
||||
<Show
|
||||
when={ringing()}
|
||||
fallback={
|
||||
<span class="font-mono text-[30px] font-semibold text-white tabular-nums">
|
||||
{clock(leadLeft() * LEAD_SECONDS)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-1.5 font-mono text-[22px] font-semibold"
|
||||
style={{ color: TIMER_ORANGE }}
|
||||
>
|
||||
<Glyph
|
||||
name="bell"
|
||||
size={18}
|
||||
style={{ animation: "nook-pulse 1s ease-in-out infinite" }}
|
||||
/>
|
||||
Time's up
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class={ringing() ? "nook-tape-ring" : ""}>
|
||||
<TimerTapeMock remaining={ringing() ? 1 : leadLeft()} width={232} height={32} />
|
||||
</div>
|
||||
<p class="font-mono text-[9px] font-medium text-white/50">
|
||||
{ringing() ? "3:00 timer" : "Remaining of 3:00"}
|
||||
</p>
|
||||
<div class="flex w-full gap-2 pt-1">
|
||||
<Show
|
||||
when={ringing()}
|
||||
fallback={
|
||||
<>
|
||||
<TimerPill glyph="pause" label="Pause" />
|
||||
<TimerPill glyph="xmark" label="Cancel" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TimerPill glyph="plus" label="1 min" />
|
||||
<TimerPill glyph="restart" label="Restart" />
|
||||
<TimerPill glyph="xmark" label="Dismiss" />
|
||||
</Show>
|
||||
</div>
|
||||
{/* a further timer: readout, slim tape, icon controls */}
|
||||
<div class="mt-1.5 flex h-6 w-full items-center gap-2 rounded-full bg-white/[0.05] px-2.5 font-mono text-[11px] font-medium text-white">
|
||||
<span class="w-11 tabular-nums">{clock(focusLeft() * 1500)}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<TimerTapeMock remaining={focusLeft()} width={110} height={12} />
|
||||
</div>
|
||||
<Glyph name="pause" size={9} />
|
||||
<Glyph name="xmark" size={9} />
|
||||
</div>
|
||||
<div class="mt-1.5 flex w-full gap-1.5">
|
||||
<For each={["1m", "5m", "10m", "25m"]}>{(m) => <TimerPill label={m} />}</For>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Weather: saved places rotate through the slot ───────────────── */
|
||||
|
||||
type Sky = "sun" | "cloud" | "rain";
|
||||
|
||||
function SkyGlyph(props: { sky: Sky; size?: number }) {
|
||||
const s = () => props.size ?? 14;
|
||||
return (
|
||||
<svg width={s()} height={s()} viewBox="0 0 16 16">
|
||||
<Show when={props.sky === "sun"}>
|
||||
<circle cx="8" cy="8" r="3.4" fill="#FFD23F" />
|
||||
<g stroke="#FFD23F" stroke-width="1.3" stroke-linecap="round">
|
||||
<path d="M8 1.2v1.6M8 13.2v1.6M1.2 8h1.6M13.2 8h1.6M3.2 3.2l1.1 1.1M11.7 11.7l1.1 1.1M3.2 12.8l1.1-1.1M11.7 4.3l1.1-1.1" />
|
||||
</g>
|
||||
</Show>
|
||||
<Show when={props.sky !== "sun"}>
|
||||
<path
|
||||
d="M4.5 11.5a3 3 0 0 1 .3-6 4 4 0 0 1 7.6 1.2 2.4 2.4 0 0 1-.4 4.8Z"
|
||||
fill="rgba(255,255,255,0.8)"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.sky === "rain"}>
|
||||
<path
|
||||
d="M6 13l-.6 1.6M9 13l-.6 1.6M12 13l-.6 1.6"
|
||||
stroke={CALM}
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</Show>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const PLACES: { place: string; temp: number; sky: Sky; hourly: [Sky, number][] }[] = [
|
||||
{
|
||||
place: "Portland",
|
||||
temp: 13,
|
||||
sky: "rain",
|
||||
hourly: [["rain", 13], ["rain", 13], ["cloud", 14], ["cloud", 15], ["sun", 16], ["sun", 15]]
|
||||
},
|
||||
{
|
||||
place: "Lisbon",
|
||||
temp: 24,
|
||||
sky: "sun",
|
||||
hourly: [["sun", 24], ["sun", 25], ["sun", 26], ["cloud", 25], ["cloud", 23], ["sun", 21]]
|
||||
}
|
||||
];
|
||||
|
||||
function WeatherMock() {
|
||||
const [i, setI] = createSignal(0);
|
||||
useDemoLoop(7000, [
|
||||
[0, () => setI(0)],
|
||||
[3500, () => setI(1)]
|
||||
]);
|
||||
const p = () => PLACES[i()];
|
||||
return (
|
||||
<div class="flex w-full max-w-[260px] flex-col items-center gap-3">
|
||||
<MiniPill class="h-[34px] flex-col gap-0 px-4">
|
||||
<SkyGlyph sky={p().sky} size={12} />
|
||||
<span class="font-mono text-[11px] leading-none font-bold text-white">
|
||||
{p().temp}°
|
||||
</span>
|
||||
</MiniPill>
|
||||
<ModuleCard class="w-full">
|
||||
<div class="text-left">
|
||||
<div class="mb-2 flex items-baseline">
|
||||
<span class="text-[11px] font-semibold text-white">{p().place}</span>
|
||||
<span class="ml-auto text-[9px] text-white/40">
|
||||
{i() + 1} of {PLACES.length}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<Index each={p().hourly}>
|
||||
{(h, hour) => (
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="text-[8px] text-white/40">
|
||||
{hour === 0 ? "Now" : `${(13 + hour) % 24}`}
|
||||
</span>
|
||||
<SkyGlyph sky={h()[0]} size={12} />
|
||||
<span class="font-mono text-[10px] text-white/85">
|
||||
{h()[1]}°
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Interceptions: volume & brightness peek below the island ────── */
|
||||
|
||||
const PEEKS = [
|
||||
{ kind: "volume", label: "Speakers", from: 0.35, to: 0.7 },
|
||||
{ kind: "bright", label: "Built-in display", from: 0.6, to: 1 },
|
||||
{ kind: "xdr", label: "Extra brightness", from: 1, to: 1.3 },
|
||||
{ kind: "bright", label: "External display", from: 0.3, to: 0.65 }
|
||||
] as const;
|
||||
|
||||
function InterceptionMock() {
|
||||
const [peek, setPeek] = createSignal(0);
|
||||
const [level, setLevel] = createSignal<number>(PEEKS[0].from);
|
||||
const [shown, setShown] = createSignal(false);
|
||||
const steps: [number, () => void][] = [];
|
||||
PEEKS.forEach((pk, n) => {
|
||||
const base = n * 2600;
|
||||
steps.push([base, () => (setPeek(n), setLevel(pk.from), setShown(true))]);
|
||||
for (let k = 1; k <= 4; k++) {
|
||||
steps.push([
|
||||
base + 250 + k * 280,
|
||||
() => setLevel(pk.from + ((pk.to - pk.from) * k) / 4)
|
||||
]);
|
||||
}
|
||||
steps.push([base + 2300, () => setShown(false)]);
|
||||
});
|
||||
useDemoLoop(PEEKS.length * 2600, steps);
|
||||
|
||||
const pk = () => PEEKS[peek()];
|
||||
const xdr = () => level() > 1;
|
||||
return (
|
||||
<div class="flex w-full max-w-[360px] flex-col items-center">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-b-[16px] bg-black transition-all duration-300"
|
||||
style={{
|
||||
width: shown() ? "300px" : "200px",
|
||||
height: shown() ? "68px" : "26px",
|
||||
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)",
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center gap-2.5 px-4 pb-3 transition-opacity duration-200"
|
||||
style={{ opacity: shown() ? 1 : 0 }}
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 16 16"
|
||||
style={{ color: xdr() ? "#FFD23F" : "white" }}
|
||||
>
|
||||
<Show
|
||||
when={pk().kind === "volume"}
|
||||
fallback={
|
||||
<g fill="currentColor" stroke="currentColor" stroke-width="1.3" stroke-linecap="round">
|
||||
<circle cx="8" cy="8" r="3" stroke="none" />
|
||||
<path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1" fill="none" />
|
||||
</g>
|
||||
}
|
||||
>
|
||||
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
|
||||
<path d="M11 5.5a3.5 3.5 0 0 1 0 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
|
||||
</Show>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="mb-1 text-left text-[9px] text-white/50">{pk().label}</p>
|
||||
<div
|
||||
class="relative h-[6px] rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.12)" }}
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(level(), 1) * (pk().kind === "xdr" ? 76.9 : 100)}%`,
|
||||
background: "white",
|
||||
transition: "width 0.2s ease-out"
|
||||
}}
|
||||
/>
|
||||
<Show when={pk().kind === "xdr"}>
|
||||
<span
|
||||
class="absolute top-[-3px] h-[12px] w-px bg-white/50"
|
||||
style={{ left: "76.9%" }}
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 h-full rounded-r-full"
|
||||
style={{
|
||||
left: "76.9%",
|
||||
width: `${Math.max(0, level() - 1) * 76.9}%`,
|
||||
background: "#FFD23F",
|
||||
"box-shadow": "0 0 10px 2px rgba(255,210,63,0.6)",
|
||||
transition: "width 0.2s ease-out"
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<span class="w-9 text-right font-mono text-[10px] text-white/80">
|
||||
{Math.round(level() * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Network: speed test on demand + identity rows ───────────────── */
|
||||
|
||||
function NetworkMock() {
|
||||
const [down, setDown] = createSignal(0);
|
||||
const [up, setUp] = createSignal(0);
|
||||
const [running, setRunning] = createSignal(false);
|
||||
const steps: [number, () => void][] = [
|
||||
[0, () => (setDown(0), setUp(0), setRunning(false))],
|
||||
[1200, () => setRunning(true)]
|
||||
];
|
||||
for (let k = 1; k <= 12; k++) {
|
||||
steps.push([1200 + k * 120, () => setDown(Math.round((842 * k) / 12))]);
|
||||
steps.push([2800 + k * 90, () => setUp(Math.round((96 * k) / 12))]);
|
||||
}
|
||||
steps.push([4000, () => setRunning(false)]);
|
||||
useDemoLoop(8400, steps);
|
||||
|
||||
return (
|
||||
<ModuleCard class="w-full max-w-[260px]">
|
||||
<div class="space-y-2 text-left">
|
||||
<div class="flex items-end gap-3">
|
||||
<div>
|
||||
<p class="text-[8px] font-semibold text-white/40">DOWN</p>
|
||||
<p class="font-mono text-[16px] font-semibold text-white">
|
||||
{down()}
|
||||
<span class="ml-0.5 text-[9px] text-white/50">Mbps</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[8px] font-semibold text-white/40">UP</p>
|
||||
<p class="font-mono text-[16px] font-semibold text-white">
|
||||
{up()}
|
||||
<span class="ml-0.5 text-[9px] text-white/50">Mbps</span>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white"
|
||||
style={{ background: running() ? "rgba(255,255,255,0.1)" : ACCENT }}
|
||||
>
|
||||
{running() ? "testing…" : "Run"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-px" style={{ background: "rgba(255,255,255,0.06)" }} />
|
||||
<For
|
||||
each={[
|
||||
["Public IP", "203.0.113.42"],
|
||||
["ISP", "Example Fiber · AS64500"],
|
||||
["DNS", "1.1.1.1"]
|
||||
]}
|
||||
>
|
||||
{([k, v]) => (
|
||||
<div class="flex items-center gap-2 text-[10px]">
|
||||
<span class="w-14 text-white/40">{k}</span>
|
||||
<span class="truncate font-mono text-white/80">{v}</span>
|
||||
<span class="ml-auto text-white/30">⧉</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[9px] font-semibold"
|
||||
style={{ background: "rgba(110,167,255,0.18)", color: "#9cc3ff" }}
|
||||
>
|
||||
VPN · default route
|
||||
</span>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Disk: throughput trace, capacity, SMART ─────────────────────── */
|
||||
|
||||
function DiskMock() {
|
||||
const tick = useTick(450);
|
||||
const sample = (n: number, k: number) => {
|
||||
const x = Math.sin(n * 0.9 + k * 2.1) * 0.5 + Math.sin(n * 0.37 + k) * 0.5;
|
||||
return Math.max(0.05, Math.min(1, 0.45 + x * 0.45));
|
||||
};
|
||||
const path = (k: number) => {
|
||||
const pts = Array.from({ length: 24 }, (_, i) => {
|
||||
const v = sample(tick() + i, k) * (k === 0 ? 1 : 0.45);
|
||||
return `${(i / 23) * 200},${40 - v * 36}`;
|
||||
});
|
||||
return `M${pts.join(" L")}`;
|
||||
};
|
||||
const read = () => Math.round(sample(tick() + 23, 0) * 900);
|
||||
const write = () => Math.round(sample(tick() + 23, 1) * 0.45 * 900);
|
||||
return (
|
||||
<ModuleCard class="w-full max-w-[260px]">
|
||||
<div class="space-y-2 text-left">
|
||||
<div class="flex items-baseline gap-3 font-mono text-[11px]">
|
||||
<span style={{ color: CALM }}>R {read()} MB/s</span>
|
||||
<span style={{ color: WARM }}>W {write()} MB/s</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 200 42" class="h-[42px] w-full" preserveAspectRatio="none">
|
||||
<path d={path(0)} fill="none" stroke={CALM} stroke-width="1.5" />
|
||||
<path d={path(1)} fill="none" stroke={WARM} stroke-width="1.5" />
|
||||
</svg>
|
||||
<div class="flex items-center text-[10px]">
|
||||
<span class="text-white/70">Macintosh HD</span>
|
||||
<span class="ml-auto text-white/45">612 GB free of 1 TB</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-[5px] overflow-hidden rounded-full"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
<div class="h-full rounded-full" style={{ width: "39%", background: CALM }} />
|
||||
</div>
|
||||
<span
|
||||
class="inline-block rounded-full px-2 py-0.5 text-[9px] font-semibold"
|
||||
style={{ background: "rgba(111,185,130,0.18)", color: STATUS.ready }}
|
||||
>
|
||||
SMART · Verified
|
||||
</span>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Keep Awake: four ways to hold off sleep ─────────────────────── */
|
||||
|
||||
const AWAKE_MODES = [
|
||||
{ label: "1 hour", note: "awake for 59:12 more" },
|
||||
{ label: "On power", note: "awake while plugged in" },
|
||||
{ label: "Lid closed", note: "keeps running with the lid shut" },
|
||||
{ label: "Agents", note: "awake only while agents work" }
|
||||
];
|
||||
|
||||
function KeepAwakeMock() {
|
||||
const [mode, setMode] = createSignal(0);
|
||||
useDemoLoop(
|
||||
9600,
|
||||
AWAKE_MODES.map((_, n) => [n * 2400, () => setMode(n)] as [number, () => void])
|
||||
);
|
||||
return (
|
||||
<ModuleCard class="w-full max-w-[260px]">
|
||||
<div class="flex flex-col items-center gap-2.5">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" class="overflow-visible">
|
||||
<g class="nook-steam" stroke="rgba(255,255,255,0.5)" stroke-width="1.6" fill="none" stroke-linecap="round">
|
||||
<path d="M15 12c-2-3 2-4 0-7" />
|
||||
<path d="M21 12c-2-3 2-4 0-7" style={{ "animation-delay": "-0.6s" }} />
|
||||
</g>
|
||||
<path d="M8 15h22v9a9 9 0 0 1-9 9h-4a9 9 0 0 1-9-9Z" fill={WARM} />
|
||||
<path d="M30 18h2a4 4 0 0 1 0 8h-2" stroke={WARM} stroke-width="2.4" fill="none" />
|
||||
</svg>
|
||||
<div class="grid w-full grid-cols-2 gap-1.5">
|
||||
<For each={AWAKE_MODES}>
|
||||
{(m, n) => (
|
||||
<span
|
||||
class="rounded-md py-1 text-center text-[10px] font-semibold transition-colors duration-300"
|
||||
style={{
|
||||
background: mode() === n() ? ACCENT : CARD_FILL,
|
||||
color: mode() === n() ? "white" : "rgba(255,255,255,0.55)",
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
|
||||
}}
|
||||
>
|
||||
{m.label}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<p class="text-[10px] text-white/45">{AWAKE_MODES[mode()].note}</p>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Approval modes: Auto · Yolo · Verify ────────────────────────── */
|
||||
|
||||
const MODES = [
|
||||
{ name: "Auto", note: "follows each agent's own settings", card: "asks", tint: "rgba(234,179,8,0.10)" },
|
||||
{ name: "Yolo", note: "automatically allows received requests", card: "passed", tint: "rgba(111,185,130,0.12)" },
|
||||
{ name: "Verify", note: "received requests wait for approval", card: "held", tint: "rgba(229,72,77,0.12)" }
|
||||
];
|
||||
|
||||
function ApprovalModesMock() {
|
||||
const [mode, setMode] = createSignal(0);
|
||||
useDemoLoop(8400, [
|
||||
[0, () => setMode(0)],
|
||||
[2800, () => setMode(1)],
|
||||
[5600, () => setMode(2)]
|
||||
]);
|
||||
const m = () => MODES[mode()];
|
||||
return (
|
||||
<div class="w-full max-w-[260px] space-y-2.5">
|
||||
<div
|
||||
class="relative flex rounded-lg p-0.5"
|
||||
style={{ background: CARD_FILL, "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 bottom-0.5 rounded-md transition-all duration-300"
|
||||
style={{
|
||||
left: `calc(${mode() * 33.333}% + 2px)`,
|
||||
width: "calc(33.333% - 4px)",
|
||||
background: ACCENT
|
||||
}}
|
||||
/>
|
||||
<For each={MODES}>
|
||||
{(x, n) => (
|
||||
<span
|
||||
class="relative z-10 flex-1 py-1 text-center text-[11px] font-semibold transition-colors"
|
||||
style={{ color: mode() === n() ? "white" : "rgba(255,255,255,0.55)" }}
|
||||
>
|
||||
{x.name}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-[10px] p-2.5 text-left transition-colors duration-500"
|
||||
style={{ background: m().tint }}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[12px] font-medium text-white">Bash</span>
|
||||
<span class="truncate font-mono text-[10px] text-white/50">
|
||||
rm -rf build/
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-1.5 py-0.5 text-[9px] font-semibold text-white"
|
||||
style={{ background: "rgba(255,255,255,0.12)" }}
|
||||
>
|
||||
{m().card}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center text-[10px] text-white/45">{m().note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Lock Screen: counts without unlocking ───────────────────────── */
|
||||
|
||||
function LockScreenMock() {
|
||||
const [counts, setCounts] = createSignal({ working: 3, waiting: 0 });
|
||||
useDemoLoop(7200, [
|
||||
[0, () => setCounts({ working: 3, waiting: 0 })],
|
||||
[2400, () => setCounts({ working: 2, waiting: 1 })],
|
||||
[4800, () => setCounts({ working: 1, waiting: 0 })]
|
||||
]);
|
||||
return (
|
||||
<div
|
||||
class="relative h-[150px] w-full max-w-[260px] overflow-hidden rounded-xl"
|
||||
style={{
|
||||
background: "radial-gradient(120% 90% at 30% 10%, #2b4a6b 0%, #191b2e 55%, #0d0d0f 100%)"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-1/2 flex h-[22px] -translate-x-1/2 items-center gap-2 rounded-b-[9px] bg-black px-2.5"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="h-[6px] w-[6px] rounded-[1.5px]" style={{ background: STATUS.running }} />
|
||||
<span class="font-mono text-[9px] font-semibold text-white/90">{counts().working}</span>
|
||||
</span>
|
||||
<span class="w-6" />
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="h-[6px] w-[6px] rounded-[1.5px] transition-colors"
|
||||
style={{ background: counts().waiting ? STATUS.attention : STATUS.idle }}
|
||||
/>
|
||||
<span class="font-mono text-[9px] font-semibold text-white/90">{counts().waiting}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-full flex-col items-center justify-center pt-4">
|
||||
<p class="text-[34px] leading-tight font-semibold tracking-tight text-white/90">
|
||||
9:41
|
||||
</p>
|
||||
<span class="mt-2 h-5 w-5 rounded-full border border-white/30" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Layout: the mosaic reflows ──────────────────────────────────── */
|
||||
|
||||
type Box = [l: number, t: number, w: number, h: number];
|
||||
const LAYOUTS: Box[][] = [
|
||||
[[0, 0, 66, 58], [68, 0, 32, 58], [0, 62, 32, 38], [34, 62, 32, 38], [68, 62, 32, 38]],
|
||||
[[0, 0, 32, 100], [34, 0, 66, 40], [34, 44, 32, 56], [68, 44, 32, 26], [68, 74, 32, 26]],
|
||||
[[0, 0, 49, 48], [51, 0, 49, 48], [0, 52, 100, 22], [0, 78, 49, 22], [51, 78, 49, 22]]
|
||||
];
|
||||
const TILE_COLORS = [CALM, STATUS.ready, WARM, "#fa5c7c", "rgba(255,255,255,0.5)"];
|
||||
|
||||
function LayoutMock() {
|
||||
const [n, setN] = createSignal(0);
|
||||
useDemoLoop(7500, [
|
||||
[0, () => setN(0)],
|
||||
[2500, () => setN(1)],
|
||||
[5000, () => setN(2)]
|
||||
]);
|
||||
return (
|
||||
<div
|
||||
class="relative h-[130px] w-full max-w-[240px] rounded-[12px] bg-black p-2"
|
||||
style={{ "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
|
||||
>
|
||||
<div class="relative h-full w-full">
|
||||
<For each={TILE_COLORS}>
|
||||
{(color, i) => {
|
||||
const b = () => LAYOUTS[n()][i()];
|
||||
return (
|
||||
<div
|
||||
class="absolute rounded-md"
|
||||
style={{
|
||||
left: `${b()[0]}%`,
|
||||
top: `${b()[1]}%`,
|
||||
width: `${b()[2]}%`,
|
||||
height: `${b()[3]}%`,
|
||||
background: CARD_FILL,
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}, inset 3px 0 0 ${color}`,
|
||||
transition: "all 0.7s cubic-bezier(0.34,1.3,0.64,1)"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Marquee ─────────────────────────────────────────────────────── */
|
||||
|
||||
const CHIPS_A = [
|
||||
"18 agents",
|
||||
"Custom agents",
|
||||
"Six chiptunes",
|
||||
"Focus-aware slots",
|
||||
"Per-app output routing",
|
||||
"Low Power Mode toggle",
|
||||
"Reminders",
|
||||
"Battery health",
|
||||
"Energy flow in watts",
|
||||
"Thermal charge protection",
|
||||
"Top-up charging",
|
||||
"Memory by process"
|
||||
];
|
||||
const CHIPS_B = [
|
||||
"Jump to the terminal",
|
||||
"tmux · iTerm2 · kitty",
|
||||
"Markdown replies",
|
||||
"Tailscale hosts",
|
||||
"iPhone widgets",
|
||||
"Watch-face widgets",
|
||||
"Screenshots to clipboard",
|
||||
"System audio + mic",
|
||||
"Hourly forecast",
|
||||
"External display brightness",
|
||||
"Lid-closed keep-awake"
|
||||
];
|
||||
|
||||
function MarqueeRow(props: { chips: string[]; reverse?: boolean }) {
|
||||
return (
|
||||
<div class="nook-marquee">
|
||||
<div class={`nook-marquee-track ${props.reverse ? "reverse" : ""}`}>
|
||||
<For each={[...props.chips, ...props.chips]}>
|
||||
{(chip, i) => (
|
||||
<span
|
||||
aria-hidden={i() >= props.chips.length ? "true" : undefined}
|
||||
class="border-overlay0 bg-surface0 text-subtext0 rounded-full border px-3.5 py-1.5 text-xs font-semibold whitespace-nowrap"
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IslandExtras() {
|
||||
return (
|
||||
<section class="bg-base relative z-20 py-16" id="island-extras">
|
||||
<div class="px-4 md:px-8">
|
||||
<p class="text-subtext1 mb-2 text-center text-xs font-semibold tracking-[0.18em] uppercase">
|
||||
More modules
|
||||
</p>
|
||||
<h2 class="text-text mb-3 text-center text-3xl font-bold">
|
||||
Other tools in the island
|
||||
</h2>
|
||||
<p class="text-subtext0 mx-auto mb-10 max-w-2xl text-center leading-relaxed">
|
||||
Add the modules you need and arrange them in your panels.
|
||||
They're all included with your license.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-12 space-y-3">
|
||||
<MarqueeRow chips={CHIPS_A} />
|
||||
<MarqueeRow chips={CHIPS_B} reverse />
|
||||
</div>
|
||||
|
||||
<div class="mx-auto grid max-w-5xl grid-cols-1 gap-5 px-4 md:grid-cols-3 md:px-8">
|
||||
<CollageCell
|
||||
class="md:col-span-2"
|
||||
kicker="Now Playing"
|
||||
title="Playback controls and track information"
|
||||
body="See what's playing and control playback. For sources without artwork, including some browsers, the compact view shows an audio waveform."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center">
|
||||
<NowPlayingMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Timers"
|
||||
title="Run several timers at once"
|
||||
body="Keep multiple timers running and check their progress in the island. When a timer finishes, it rings and keeps the island open until you dismiss it."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<TimersMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Weather"
|
||||
title="Weather for your saved places"
|
||||
body="Check hourly and daily forecasts for saved places. The compact view shows the current conditions and temperature."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<WeatherMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
class="md:col-span-2"
|
||||
kicker="Interceptions"
|
||||
title="Volume and brightness controls"
|
||||
body="Show volume and brightness changes below the island instead of the system overlay. Adjust supported external displays and use extra brightness on compatible XDR displays."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center">
|
||||
<div class="flex min-h-[110px] w-full justify-center pt-2">
|
||||
<InterceptionMock />
|
||||
</div>
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Approval modes"
|
||||
title="Choose how approvals are handled"
|
||||
body="Auto follows each agent's settings. Yolo automatically allows requests that reach The Nook. Verify waits for your decision on each request it receives."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<ApprovalModesMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Network"
|
||||
title="Network information and speed tests"
|
||||
body="View and copy your public IP, ISP, gateway, and DNS details. Check for VPN routes or run a speed test."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<NetworkMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Disks"
|
||||
title="Disk usage and drive health"
|
||||
body="Check read and write speeds, free space, and SMART health information for supported drives."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<DiskMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Keep Awake"
|
||||
title="Keep your Mac awake"
|
||||
body="Keep your Mac awake for a set time, while plugged in, with the lid closed, or while your agents are working."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<KeepAwakeMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Lock Screen"
|
||||
title="Agent status on the Lock Screen"
|
||||
body="The island stays on your Lock Screen, so you can see how many agents are working or waiting without unlocking."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<LockScreenMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
|
||||
<CollageCell
|
||||
kicker="Layout"
|
||||
title="Arrange your panels"
|
||||
body="Drag and resize modules, choose how much detail they show, and change compact-view priorities with your Focus mode."
|
||||
>
|
||||
<InkStage class="h-full w-full items-center px-3">
|
||||
<LayoutMock />
|
||||
</InkStage>
|
||||
</CollageCell>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { For, createMemo, onMount, onCleanup, type JSX } from "solid-js";
|
||||
import { Campfire, type CampfireState } from "./Campfire";
|
||||
|
||||
/**
|
||||
* Faithful mock of the island's collapsed pill and expanded panel.
|
||||
* Geometry follows the app's IslandSurfaceShape: the top edge spans the
|
||||
* full width, the notch housing sits centered, and each corner is a
|
||||
* concave scoop (quadratic curve with the control ON the top edge).
|
||||
*/
|
||||
|
||||
/* The app's island indicator palette (AgentsModuleView.statusColor). */
|
||||
export const STATUS = {
|
||||
running: "#6EA7FF",
|
||||
ready: "#6FB982",
|
||||
attention: "#E7A762",
|
||||
error: "#E5484D",
|
||||
idle: "rgba(255,255,255,0.35)"
|
||||
};
|
||||
|
||||
/** MetricPalette.calm — the desaturated sky of every metric bar. */
|
||||
export const CALM = "#6EA7FF";
|
||||
export const WARM = "#E7A762";
|
||||
export const CRITICAL = "rgba(229,72,77,0.9)";
|
||||
|
||||
/* NookPalette.accent — brand + live interactive state, never status. */
|
||||
export const ACCENT = "#4897b2";
|
||||
|
||||
/** The expanded panel's card chrome: white 5.5% fill, white 7% stroke. */
|
||||
export const CARD_FILL = "rgba(255,255,255,0.055)";
|
||||
export const CARD_STROKE = "rgba(255,255,255,0.07)";
|
||||
|
||||
/**
|
||||
* One module-card surface shared by every mock panel — the app's
|
||||
* cardChrome(): rounded 12, hairline stroke, white-on-black fill.
|
||||
*/
|
||||
export function ModuleCard(props: {
|
||||
children: JSX.Element;
|
||||
class?: string;
|
||||
style?: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={`rounded-xl ${props.class ?? ""}`}
|
||||
style={{
|
||||
background: CARD_FILL,
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`,
|
||||
...props.style
|
||||
}}
|
||||
>
|
||||
<div class="p-3">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Island surface, faithful to the app's IslandSurfaceShape: the top edge
|
||||
* spans the full outer width while the body is inset, each corner joining
|
||||
* them with a concave quad flare (control point ON the top edge), the
|
||||
* bottom a convex 14px round. Rendered as an inline SVG with
|
||||
* foreignObject-clip via CSS `clip-path: path(...)`.
|
||||
*/
|
||||
const FLARE = 22; // the app's topRadius — how far the wings taper inward
|
||||
const BOTTOM_R = 14;
|
||||
|
||||
function islandPath(w: number, h: number): string {
|
||||
const tr = Math.min(FLARE, w / 2);
|
||||
const br = Math.min(BOTTOM_R, tr);
|
||||
// Mirrors IslandSurfaceShape.path exactly (same node order, same controls).
|
||||
return (
|
||||
`M0,0 L${w},0 ` +
|
||||
`Q${w - tr},0 ${w - tr},${tr} ` +
|
||||
`L${w - tr},${h - br} ` +
|
||||
`Q${w - tr},${h} ${w - tr - br},${h} ` +
|
||||
`L${tr + br},${h} ` +
|
||||
`Q${tr},${h} ${tr},${h - br} ` +
|
||||
`L${tr},${tr} ` +
|
||||
`Q${tr},0 0,0 Z`
|
||||
);
|
||||
}
|
||||
|
||||
function IslandSurface(props: { children: JSX.Element; class?: string }) {
|
||||
let el: HTMLDivElement | undefined;
|
||||
// The path must track the element's real size; a fixed viewBox would
|
||||
// stretch the flare. Measure on mount + resize.
|
||||
onMount(() => {
|
||||
const node = el;
|
||||
if (!node) return;
|
||||
// Layout size, not getBoundingClientRect: a scaled-down mock (the
|
||||
// Capture demo's island row) would otherwise clip itself to a corner.
|
||||
const apply = () => {
|
||||
node.style.clipPath = `path("${islandPath(node.offsetWidth, node.offsetHeight)}")`;
|
||||
};
|
||||
apply();
|
||||
const observer = new ResizeObserver(apply);
|
||||
observer.observe(node);
|
||||
onCleanup(() => observer.disconnect());
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={el}
|
||||
class={`bg-black ${props.class ?? ""}`}
|
||||
style={{ "border-radius": "0 0 14px 14px" }}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ISLAND … */
|
||||
const NOTCH_NAME = "The Nook";
|
||||
|
||||
/* ── Collapsed pill ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The collapsed pill: campfire slot left, camera housing center, live
|
||||
* data slot right. Slot squares are native (no menu-bar stub — the wings
|
||||
* hang from nothing, like a floating notch).
|
||||
*/
|
||||
export function IslandPill(props: {
|
||||
campfire?: CampfireState;
|
||||
right?: JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<IslandSurface class="mx-auto w-fit">
|
||||
<div class="flex items-end" style={{ height: "60px" }}>
|
||||
<div
|
||||
class="flex items-center justify-center"
|
||||
style={{ width: "86px", height: "56px" }}
|
||||
>
|
||||
<Campfire state={props.campfire ?? "running"} pixel={4.2} />
|
||||
</div>
|
||||
{/* camera housing: lens dot centered like the real notch */}
|
||||
<div
|
||||
class="flex items-center justify-center"
|
||||
style={{ width: "150px", height: "56px" }}
|
||||
>
|
||||
<span
|
||||
class="rounded-full"
|
||||
style={{
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
background: "radial-gradient(circle at 40% 35%, #2a3a4a 0%, #0a0c10 70%)",
|
||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.06)"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-center"
|
||||
style={{ width: "86px", height: "56px" }}
|
||||
>
|
||||
{props.right}
|
||||
</div>
|
||||
</div>
|
||||
</IslandSurface>
|
||||
);
|
||||
}
|
||||
|
||||
/** The agents dot-grid slot indicator: balanced rows of status squares. */
|
||||
export function AgentDotGrid(props: { states: (keyof typeof STATUS)[] }) {
|
||||
// Balanced rows: 3+3+2 for 8, 2x2 for 4, 3 for 3 … app: 1,2,3,4=2x2,
|
||||
// 5=[3,2], 6=[3,3], 7=[4,3], 8=[4,4], 9=[3,3,3]
|
||||
const rows = (n: number): number[] => {
|
||||
switch (n) {
|
||||
case 1:
|
||||
return [1];
|
||||
case 2:
|
||||
return [2];
|
||||
case 3:
|
||||
return [3];
|
||||
case 4:
|
||||
return [2, 2];
|
||||
case 5:
|
||||
return [3, 2];
|
||||
case 6:
|
||||
return [3, 3];
|
||||
case 7:
|
||||
return [4, 3];
|
||||
case 8:
|
||||
return [4, 4];
|
||||
default:
|
||||
return [4, 4];
|
||||
}
|
||||
};
|
||||
const sizes = rows(props.states.length);
|
||||
const groups = createMemo(() => {
|
||||
let cursor = 0;
|
||||
return sizes.map((count) => props.states.slice(cursor, (cursor += count)));
|
||||
});
|
||||
return (
|
||||
<div class="flex flex-col items-center gap-[1.5px]">
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="flex gap-[1.5px]">
|
||||
<For each={group}>
|
||||
{(s) => (
|
||||
<span
|
||||
class="rounded-[1.5px]"
|
||||
style={{
|
||||
width: "7px",
|
||||
height: "7px",
|
||||
background: STATUS[s]
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ── Expanded panel ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* The expanded island: header ("The Nook" + "N active" + controls),
|
||||
* tab strip, divider, then one panel page. Width ~ the app's expanded
|
||||
* footprint on a 14" display.
|
||||
*/
|
||||
export function IslandExpanded(props: {
|
||||
activeCount: number;
|
||||
tabs: { icon: string; label: string; active?: boolean }[];
|
||||
children: JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<div class="w-[560px] max-w-full">
|
||||
<IslandSurface>
|
||||
{/* header */}
|
||||
<div class="flex items-center px-[18px] py-2.5">
|
||||
<span class="text-[14px] font-semibold text-white">{NOTCH_NAME}</span>
|
||||
<div class="flex-1" />
|
||||
<span class="text-[11px] text-white/50">
|
||||
{props.activeCount} active
|
||||
</span>
|
||||
<span class="ml-3 text-[11px] font-semibold text-white/60">⚙</span>
|
||||
<span class="ml-3 text-[11px] font-semibold text-white/60">✕</span>
|
||||
</div>
|
||||
{/* tab strip: one icon button per panel; active wears accent 22% */}
|
||||
<div class="flex gap-2 px-[18px] pb-1.5">
|
||||
<For each={props.tabs}>
|
||||
{(tab) => (
|
||||
<span
|
||||
title={tab.label}
|
||||
class="flex items-center justify-center rounded-[5px]"
|
||||
style={{
|
||||
width: "22px",
|
||||
height: "22px",
|
||||
"font-size": "13px",
|
||||
background: tab.active ? `${ACCENT}38` : "transparent",
|
||||
color: tab.active ? "rgba(255,255,255,0.95)" : "rgba(255,255,255,0.5)"
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="h-px bg-white/10" />
|
||||
<div class="px-[18px] py-4">{props.children}</div>
|
||||
</IslandSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fake cursor dot: rests on a target, presses, exits. */
|
||||
export function FakeCursor(props: {
|
||||
phase: "idle" | "press" | "live";
|
||||
at: { x: number; y: number };
|
||||
exit?: { x: number; y: number };
|
||||
}) {
|
||||
const p = () =>
|
||||
props.phase === "press"
|
||||
? { ...props.at, scale: 0.62, o: 1 }
|
||||
: props.phase === "idle"
|
||||
? { ...props.at, scale: 1, o: 1 }
|
||||
: { ...(props.exit ?? { x: 106, y: 8 }), scale: 1, o: 0 };
|
||||
return (
|
||||
<span
|
||||
class="pointer-events-none absolute z-10 rounded-full bg-white"
|
||||
style={{
|
||||
width: "9px",
|
||||
height: "9px",
|
||||
"box-shadow":
|
||||
"0 1px 4px rgba(0,0,0,0.6), 0 0 0 4px rgba(255,255,255,0.18)",
|
||||
left: `${p().x}%`,
|
||||
top: `${p().y}%`,
|
||||
opacity: p().o,
|
||||
transform: `translate(-50%, -50%) scale(${p().scale})`,
|
||||
transition:
|
||||
"left 0.8s cubic-bezier(0.35,0,0.25,1), top 0.8s cubic-bezier(0.35,0,0.25,1), opacity 0.4s, transform 0.3s"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
import { For, Show, type JSX } from "solid-js";
|
||||
import { STATUS, ACCENT, CALM, WARM, CARD_FILL, CARD_STROKE } from "./IslandMock";
|
||||
|
||||
/**
|
||||
* Module-level mocks rebuilt from the app's SwiftUI views, so the site shows
|
||||
* the same anatomy the island does: the session row (SessionRowView), the
|
||||
* approval card (AgentsActionCards), the timer tape (TimerTape), a
|
||||
* temperature line (TemperaturesPanelView), and the Wings (WingPanels).
|
||||
*/
|
||||
|
||||
/* ── Glyphs: the SF Symbols the mocked views use ─────────────────── */
|
||||
|
||||
/* Factories, not shared nodes: a Solid JSX node can mount in only one place. */
|
||||
const GLYPHS: Record<string, () => JSX.Element> = {
|
||||
speaker: () => (
|
||||
<>
|
||||
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
|
||||
<path d="M11 5.5a3.5 3.5 0 0 1 0 5M12.8 3.6a6 6 0 0 1 0 8.8" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
|
||||
</>
|
||||
),
|
||||
speakerOff: () => (
|
||||
<>
|
||||
<path d="M2 6h3l4-3v10l-4-3H2Z" fill="currentColor" />
|
||||
<path d="m11 6 4 4m0-4-4 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
|
||||
</>
|
||||
),
|
||||
mic: () => (
|
||||
<>
|
||||
<rect x="5.5" y="1.5" width="5" height="8.5" rx="2.5" fill="currentColor" />
|
||||
<path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
|
||||
</>
|
||||
),
|
||||
micOff: () => (
|
||||
<>
|
||||
<rect x="5.5" y="1.5" width="5" height="8.5" rx="2.5" fill="currentColor" opacity="0.9" />
|
||||
<path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5M2.5 2.5l11 11" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round" />
|
||||
</>
|
||||
),
|
||||
pause: () => (
|
||||
<>
|
||||
<rect x="4" y="3" width="3" height="10" rx="1" fill="currentColor" />
|
||||
<rect x="9" y="3" width="3" height="10" rx="1" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
play: () => <path d="M5 3v10l8-5Z" fill="currentColor" />,
|
||||
stop: () => <rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />,
|
||||
viewfinder: () => (
|
||||
<path d="M2 5V3.5A1.5 1.5 0 0 1 3.5 2H5m6 0h1.5A1.5 1.5 0 0 1 14 3.5V5m0 6v1.5a1.5 1.5 0 0 1-1.5 1.5H11m-6 0H3.5A1.5 1.5 0 0 1 2 12.5V11m6-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" />
|
||||
),
|
||||
record: () => (
|
||||
<>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.3" fill="none" />
|
||||
<circle cx="8" cy="8" r="3" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
dashed: () => (
|
||||
<rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" stroke-width="1.3" stroke-dasharray="2 1.6" fill="none" />
|
||||
),
|
||||
display: () => (
|
||||
<path d="M2.5 3h11a1 1 0 0 1 1 1v6.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm3 11h5M8 11.5V14" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" />
|
||||
),
|
||||
window: () => (
|
||||
<>
|
||||
<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="currentColor" stroke-width="1.3" fill="none" />
|
||||
<path d="M1.5 6h13" stroke="currentColor" stroke-width="1.3" />
|
||||
</>
|
||||
),
|
||||
chevron: () => <path d="m4.5 6.5 3.5 3.5 3.5-3.5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round" />,
|
||||
film: () => (
|
||||
<path d="M3 2.5h10a.5.5 0 0 1 .5.5v10a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5Zm2 0v11m6-11v11M2.5 6h2.5m6 0h2.5m-11 4h2.5m6 0h2.5" stroke="currentColor" stroke-width="1.2" fill="none" />
|
||||
),
|
||||
xmark: () => <path d="m4 4 8 8m0-8-8 8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />,
|
||||
bell: () => (
|
||||
<path d="M8 1.8a4.2 4.2 0 0 0-4.2 4.2v3L2.5 11.3h11L12.2 9V6A4.2 4.2 0 0 0 8 1.8ZM6.3 12.8a1.8 1.8 0 0 0 3.4 0Z" fill="currentColor" />
|
||||
),
|
||||
restart: () => (
|
||||
<path d="M3.5 8a4.5 4.5 0 1 0 1.4-3.3M3.2 2.2v2.8H6" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
),
|
||||
plus: () => <path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />,
|
||||
video: () => <path d="M2 4h8v8H2Z m9 3 3-2v6l-3-2Z" fill="currentColor" />,
|
||||
pin: () => <path d="m6 2 6 6-3 1-2 3-1-2-4 4m2-8 2-2Z" stroke="currentColor" stroke-width="1.2" fill="none" stroke-linejoin="round" />,
|
||||
eye: () => <><path d="M1 8s2.5-4 7-4 7 4 7 4-2.5 4-7 4-7-4-7-4Z" stroke="currentColor" stroke-width="1.2" fill="none" /><circle cx="8" cy="8" r="2" fill="currentColor" /></>,
|
||||
plug: () => <path d="M5 1v4m6-4v4M3 5h10v3a5 5 0 0 1-4 4.9V16H7v-3.1A5 5 0 0 1 3 8Z" stroke="currentColor" stroke-width="1.3" fill="none" />,
|
||||
battery: () => <><rect x="1" y="4" width="12" height="8" rx="1.5" stroke="currentColor" stroke-width="1.2" fill="none" /><path d="M14 6v4M3 6h5v4H3Z" stroke="currentColor" stroke-width="1.2" /></>,
|
||||
laptop: () => <path d="M3 2.5h10v9H3Z M1 13.5h14" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linejoin="round" />,
|
||||
refresh: () => (
|
||||
<path d="M12.5 8A4.5 4.5 0 1 1 11 4.6M11.6 2v2.8H8.8" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
)
|
||||
};
|
||||
|
||||
export type GlyphName = keyof typeof GLYPHS;
|
||||
|
||||
export function Glyph(props: { name: GlyphName; size?: number; class?: string; style?: JSX.CSSProperties }) {
|
||||
return (
|
||||
<svg
|
||||
width={props.size ?? 12}
|
||||
height={props.size ?? 12}
|
||||
viewBox="0 0 16 16"
|
||||
class={`shrink-0 ${props.class ?? ""}`}
|
||||
style={props.style}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{GLYPHS[props.name]()}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Agents ──────────────────────────────────────────────────────── */
|
||||
|
||||
export interface AgentLook {
|
||||
name: string;
|
||||
/** brandColorHex from the app's AgentRegistry row. */
|
||||
color: string;
|
||||
/** The brand mark the app bundles (AgentBrands/*.svg). */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const CLAUDE: AgentLook = {
|
||||
name: "Claude Code",
|
||||
color: "#D97742",
|
||||
icon: "/nook/brands/claude.svg"
|
||||
};
|
||||
export const CODEX: AgentLook = {
|
||||
name: "Codex",
|
||||
color: "#4AA3DF",
|
||||
icon: "/nook/brands/codex.svg"
|
||||
};
|
||||
|
||||
/** A row badge: brand-tinted for the agent chip, neutral otherwise. */
|
||||
function Badge(props: { text: string; tint?: string }) {
|
||||
return (
|
||||
<span
|
||||
class="max-w-full truncate rounded-full px-[7px] py-[2px] text-[10px] font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
color: props.tint ?? "rgba(255,255,255,0.75)",
|
||||
background: props.tint ? `${props.tint}33` : "rgba(255,255,255,0.08)"
|
||||
}}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionRowView: "project · branch", the latest prompt, a status-colored
|
||||
* state line; on the right the agent chip (named "Agent · host" for a
|
||||
* remote session), the terminal origin, the status dot and age.
|
||||
*/
|
||||
export function SessionRowMock(props: {
|
||||
title: string;
|
||||
prompt: string;
|
||||
state: string;
|
||||
status: keyof typeof STATUS;
|
||||
agent: AgentLook;
|
||||
/** Remote sessions name their machine in the agent chip. */
|
||||
host?: string;
|
||||
origin?: string;
|
||||
age: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class="@container rounded-xl px-2 py-2.5 text-left"
|
||||
style={{ background: CARD_FILL, "box-shadow": `inset 0 0 0 1px ${CARD_STROKE}` }}
|
||||
>
|
||||
{/* The app's layout (badges in a right-hand third) wherever the row
|
||||
is wide enough; narrow site cells stack the badges beneath. */}
|
||||
<div class="flex flex-col gap-1.5 @[340px]:flex-row @[340px]:items-start @[340px]:gap-2.5">
|
||||
<div class="min-w-0 flex-1 space-y-[3px]">
|
||||
<p class="truncate text-[13px] font-semibold text-white">{props.title}</p>
|
||||
<p class="truncate text-[11.5px] text-white/55">User: {props.prompt}</p>
|
||||
<p
|
||||
class="truncate text-[11.5px] font-medium transition-colors duration-500"
|
||||
style={{ color: STATUS[props.status] }}
|
||||
>
|
||||
{props.state}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex min-w-0 shrink-0 items-start gap-1.5 @[340px]:max-w-[50%]">
|
||||
<div class="flex min-w-0 flex-col items-center">
|
||||
<Badge
|
||||
text={props.host ? `${props.agent.name} · ${props.host}` : props.agent.name}
|
||||
tint={props.agent.color}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 @[340px]:flex-col @[340px]:items-end @[340px]:gap-0.5">
|
||||
<Show when={props.origin}>
|
||||
<Badge text={props.origin!} />
|
||||
</Show>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="h-[7px] w-[7px] rounded-full transition-colors duration-500"
|
||||
style={{ background: STATUS[props.status] }}
|
||||
/>
|
||||
<span class="rounded-full bg-white/[0.08] px-[7px] py-[2px] text-[10px] text-white/80">
|
||||
{props.age}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** SwiftUI's dark-mode .yellow, which the approval card tints with. */
|
||||
const YELLOW = "255,214,10";
|
||||
|
||||
/**
|
||||
* ApprovalCard: brand mark, session title, "approval" capsule; "Run <tool>",
|
||||
* the input in mono; Allow (accent, the island's tint), Allow for session,
|
||||
* Deny, and Deny with reason….
|
||||
*/
|
||||
export function ApprovalCardMock(props: {
|
||||
agent: AgentLook;
|
||||
title: string;
|
||||
tool: string;
|
||||
input: string;
|
||||
pressed?: "allow" | "session" | "deny";
|
||||
}) {
|
||||
const button = (key: "allow" | "session" | "deny", label: string, prominent = false) => (
|
||||
<span
|
||||
class="rounded-md px-2 py-[3px] text-[11px] font-medium whitespace-nowrap text-white transition-transform duration-150"
|
||||
style={{
|
||||
background: prominent ? ACCENT : "rgba(255,255,255,0.12)",
|
||||
transform: props.pressed === key ? "scale(0.92)" : "scale(1)",
|
||||
filter: props.pressed === key ? "brightness(1.25)" : "none"
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div class="rounded-[10px] p-2.5 text-left" style={{ background: `rgba(${YELLOW},0.08)` }}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<img src={props.agent.icon} alt="" class="h-[13px] w-[13px]" />
|
||||
<span class="truncate text-[12px] font-semibold text-white">{props.title}</span>
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
style={{ background: `rgba(${YELLOW},0.25)` }}
|
||||
>
|
||||
approval
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-[13px] font-medium text-white">Run {props.tool}</p>
|
||||
<p class="mt-0.5 truncate font-mono text-[11px] text-white/50">{props.input}</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{button("allow", "Allow", true)}
|
||||
{button("session", "Allow for session")}
|
||||
{button("deny", "Deny")}
|
||||
<span class="text-[11px] whitespace-nowrap text-white/50">Deny with reason…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Timer tape ──────────────────────────────────────────────────── */
|
||||
|
||||
/** TimerPalette.accent. */
|
||||
export const TIMER_ORANGE = "#FA9E4D";
|
||||
|
||||
/**
|
||||
* TimerTapeDrawing.ticks, ported: fixed-pitch 1.5 px ticks resting at 40 %
|
||||
* of the tape height, swelling to a 72 % crest (raised cosine) that leads
|
||||
* into the remaining side and trails a shorter shoulder behind.
|
||||
*/
|
||||
function tapeTicks(remaining: number, w: number, h: number) {
|
||||
const span = w - 2;
|
||||
const count = Math.floor(span / 6) + 1;
|
||||
const pitch = span / Math.max(1, count - 1);
|
||||
const boundary = 1 + span * remaining;
|
||||
const baseline = h * 0.4;
|
||||
const peak = h * 0.72;
|
||||
const lead = Math.min(84, w * 0.4);
|
||||
const trail = lead * 0.5;
|
||||
const swell = (d: number) => (d < 1 ? (1 + Math.cos(Math.PI * d)) / 2 : 0);
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const cx = 1 + i * pitch;
|
||||
const offset = boundary - cx;
|
||||
const rise = remaining > 0 ? swell(Math.abs(offset) / (offset >= 0 ? lead : trail)) : 0;
|
||||
const height = baseline + (peak - baseline) * rise;
|
||||
const fill = remaining > 0 ? Math.min(1, Math.max(0, offset / pitch + 1)) : 0;
|
||||
return { x: cx - 0.75, y: (h - height) / 2, height, fill };
|
||||
});
|
||||
}
|
||||
|
||||
export function TimerTapeMock(props: {
|
||||
remaining: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${props.width} ${props.height}`}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
class="block max-w-full"
|
||||
style={{ opacity: props.opacity ?? 1 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<For each={tapeTicks(Math.min(1, Math.max(0, props.remaining)), props.width, props.height)}>
|
||||
{(t) => (
|
||||
<>
|
||||
<Show when={t.fill < 1}>
|
||||
<rect x={t.x} y={t.y} width="1.5" height={t.height} rx="0.75" fill="rgba(255,255,255,0.18)" />
|
||||
</Show>
|
||||
<Show when={t.fill > 0}>
|
||||
<rect
|
||||
x={t.x}
|
||||
y={t.y}
|
||||
width="1.5"
|
||||
height={t.height}
|
||||
rx="0.75"
|
||||
fill={props.color ?? TIMER_ORANGE}
|
||||
opacity={t.fill}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Temperatures ────────────────────────────────────────────────── */
|
||||
|
||||
/** MetricPalette.usage: calm below 70 %, warm to 90 %, critical above. */
|
||||
function usageColor(fraction: number) {
|
||||
if (fraction >= 0.9) return "rgba(229,72,77,0.9)";
|
||||
if (fraction >= 0.7) return WARM;
|
||||
return CALM;
|
||||
}
|
||||
|
||||
/** Tier color at the app's default thresholds (warn 70 °C, alert 85 °C). */
|
||||
function tierColor(celsius: number) {
|
||||
if (celsius >= 85) return "rgba(229,72,77,0.9)";
|
||||
if (celsius >= 70) return WARM;
|
||||
return "white";
|
||||
}
|
||||
|
||||
/**
|
||||
* One die line: label, its 60 s usage trace (filled at 20 %, 1.5 px line,
|
||||
* over a hairline floor), and the temperature in mono, tier-tinted.
|
||||
*/
|
||||
export function DieLine(props: { label: string; usage: number[]; celsius: number }) {
|
||||
const path = () => {
|
||||
const n = props.usage.length;
|
||||
return props.usage
|
||||
.map((u, i) => `${i === 0 ? "M" : "L"}${((i / (n - 1)) * 100).toFixed(2)},${(26 - u * 26).toFixed(2)}`)
|
||||
.join(" ");
|
||||
};
|
||||
const color = () => usageColor(props.usage[props.usage.length - 1] ?? 0);
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[9px] font-semibold text-white/50">{props.label}</span>
|
||||
<svg viewBox="0 0 100 26" preserveAspectRatio="none" class="h-[26px] min-w-0 flex-1" aria-hidden="true">
|
||||
<path d="M0,25.5 L100,25.5" stroke="rgba(255,255,255,0.08)" stroke-width="1" vector-effect="non-scaling-stroke" />
|
||||
<path d={`${path()} L100,26 L0,26 Z`} fill={color()} fill-opacity="0.2" />
|
||||
<path d={path()} fill="none" stroke={color()} stroke-width="1.5" vector-effect="non-scaling-stroke" />
|
||||
</svg>
|
||||
<span
|
||||
class="font-mono text-[15px] font-medium transition-colors duration-500"
|
||||
style={{ color: tierColor(props.celsius) }}
|
||||
>
|
||||
{Math.round(props.celsius)}°
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Wings ───────────────────────────────────────────────────────── */
|
||||
|
||||
export interface WingControlMock {
|
||||
glyph: GlyphName;
|
||||
on?: boolean;
|
||||
pressed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One edge's capsule: black at 94 %, a 1 px white-18 % stroke, and each
|
||||
* control a circle as tall as the Compact island, icon at 42 %.
|
||||
*/
|
||||
export function WingCapsule(props: { controls: WingControlMock[]; height: number }) {
|
||||
return (
|
||||
<div
|
||||
class="flex rounded-full"
|
||||
style={{
|
||||
height: `${props.height}px`,
|
||||
background: "rgba(0,0,0,0.94)",
|
||||
"box-shadow": "inset 0 0 0 1px rgba(255,255,255,0.18)"
|
||||
}}
|
||||
>
|
||||
<For each={props.controls}>
|
||||
{(c) => (
|
||||
<span
|
||||
class="flex items-center justify-center rounded-full transition-all duration-200"
|
||||
style={{
|
||||
width: `${props.height}px`,
|
||||
height: `${props.height}px`,
|
||||
color: `rgba(255,255,255,${c.on === false ? 0.45 : 1})`,
|
||||
background: c.pressed ? "rgba(255,255,255,0.14)" : "transparent"
|
||||
}}
|
||||
>
|
||||
<Glyph name={c.glyph} size={Math.round(props.height * 0.42)} />
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,598 +0,0 @@
|
||||
import { For, Show, createSignal, type JSX } from "solid-js";
|
||||
import {
|
||||
ModuleCard,
|
||||
FakeCursor,
|
||||
IslandPill,
|
||||
AgentDotGrid,
|
||||
STATUS,
|
||||
ACCENT,
|
||||
CALM,
|
||||
CARD_STROKE
|
||||
} from "./IslandMock";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { Glyph, WingCapsule, SessionRowMock, CODEX, type GlyphName } from "./ModuleMocks";
|
||||
|
||||
/**
|
||||
* Live demos for the breakdowns added with the 0.6–0.10 feature set:
|
||||
* terminal replies, remote setup, Capture (module + Wings), and
|
||||
* floating actions. Same ink chrome and status palette as the originals.
|
||||
*/
|
||||
|
||||
/* ── Reply lands in the agent's own terminal ──────────────────────── */
|
||||
|
||||
const REPLY = "Yes, update and run the tests.";
|
||||
|
||||
export function TerminalReplyDemo() {
|
||||
const [typed, setTyped] = createSignal(0);
|
||||
const [phase, setPhase] = createSignal<
|
||||
"asking" | "typing" | "sent" | "working" | "jump"
|
||||
>("asking");
|
||||
|
||||
useDemoLoop(10000, [
|
||||
[0, () => (setTyped(0), setPhase("asking"))],
|
||||
[900, () => setPhase("typing")],
|
||||
...REPLY.split("").map(
|
||||
(_, i) => [1000 + i * 70, () => setTyped(i + 1)] as [number, () => void]
|
||||
),
|
||||
[2900, () => setPhase("sent")],
|
||||
[3300, () => setPhase("working")],
|
||||
[6600, () => setPhase("jump")]
|
||||
]);
|
||||
|
||||
const delivered = () => phase() === "working" || phase() === "jump";
|
||||
const cursorPhase = () =>
|
||||
phase() === "sent" || phase() === "jump"
|
||||
? ("press" as const)
|
||||
: phase() === "typing" || phase() === "asking"
|
||||
? ("idle" as const)
|
||||
: ("live" as const);
|
||||
const cursorAt = () =>
|
||||
phase() === "jump" ? { x: 88, y: 14 } : { x: 88, y: 80 };
|
||||
|
||||
return (
|
||||
<div class="mx-auto flex w-full max-w-[340px] flex-col gap-3">
|
||||
<ModuleCard class="relative">
|
||||
<div class="space-y-2 text-left">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-[7px] w-[7px] rounded-full transition-colors duration-500"
|
||||
style={{
|
||||
background: delivered() ? STATUS.running : STATUS.ready
|
||||
}}
|
||||
/>
|
||||
<span class="text-[13px] font-semibold text-white">the-nook</span>
|
||||
<span class="font-mono text-[10px] text-white/40">main</span>
|
||||
<span
|
||||
class="ml-auto rounded-md px-2 py-0.5 text-[10px] font-semibold text-white/80 transition-colors duration-300"
|
||||
style={{
|
||||
background:
|
||||
phase() === "jump" ? ACCENT : "rgba(255,255,255,0.08)"
|
||||
}}
|
||||
>
|
||||
Jump ↗
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] leading-snug text-white/60">
|
||||
{delivered()
|
||||
? "Running the test suite…"
|
||||
: "Done. Want me to update the tests too?"}
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-lg px-2 py-1.5"
|
||||
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||
>
|
||||
<span class="min-h-[14px] flex-1 truncate font-mono text-[11px] text-white/85">
|
||||
{delivered() ? "" : REPLY.slice(0, typed())}
|
||||
<Show when={phase() === "typing"}>
|
||||
<span class="nook-caret">▍</span>
|
||||
</Show>
|
||||
<Show when={!typed() || delivered()}>
|
||||
<span class="text-white/30">Reply…</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span
|
||||
class="rounded-md px-2 py-0.5 text-[10px] font-semibold text-white transition-transform duration-200"
|
||||
style={{
|
||||
background: ACCENT,
|
||||
transform: phase() === "sent" ? "scale(0.9)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<FakeCursor phase={cursorPhase()} at={cursorAt()} />
|
||||
</ModuleCard>
|
||||
|
||||
{/* the terminal the session actually runs in */}
|
||||
<div
|
||||
class="overflow-hidden rounded-lg text-left transition-shadow duration-500"
|
||||
style={{
|
||||
background: "#16161a",
|
||||
"box-shadow":
|
||||
phase() === "jump"
|
||||
? `0 0 0 2px ${ACCENT}, 0 8px 30px rgba(72,151,178,0.35)`
|
||||
: `inset 0 0 0 1px ${CARD_STROKE}`
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5 px-2.5 py-1.5">
|
||||
<span class="h-2 w-2 rounded-full bg-[#ff5f57]" />
|
||||
<span class="h-2 w-2 rounded-full bg-[#febc2e]" />
|
||||
<span class="h-2 w-2 rounded-full bg-[#28c840]" />
|
||||
<span class="ml-2 font-mono text-[10px] text-white/40">
|
||||
tmux · the-nook:2
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto text-[9px] font-semibold tracking-wide uppercase transition-opacity duration-300"
|
||||
style={{ color: ACCENT, opacity: phase() === "jump" ? 1 : 0 }}
|
||||
>
|
||||
focused
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-0.5 px-2.5 pb-2.5 font-mono text-[10.5px] leading-relaxed">
|
||||
<p class="text-white/40">⏺ Updated 3 files</p>
|
||||
<p class="text-white/70">
|
||||
>{" "}
|
||||
<span
|
||||
class="transition-opacity duration-300"
|
||||
style={{ opacity: delivered() ? 1 : 0 }}
|
||||
>
|
||||
{REPLY}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
class="transition-opacity duration-500"
|
||||
style={{ color: CALM, opacity: delivered() ? 1 : 0 }}
|
||||
>
|
||||
⏺ Bash(swift test)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** RemoteMirror's progress steps, in order. */
|
||||
const SETUP_STEPS = [
|
||||
"connecting",
|
||||
"pushing the hook binary",
|
||||
"configuring agents",
|
||||
"checking the host can reach this Mac"
|
||||
];
|
||||
|
||||
export function RemoteHostDemo() {
|
||||
/* -1 not set up · 0…3 setting up · 4 live */
|
||||
const [step, setStep] = createSignal(-1);
|
||||
const [pressed, setPressed] = createSignal(false);
|
||||
useDemoLoop(11000, [
|
||||
[0, () => (setStep(-1), setPressed(false))],
|
||||
[1400, () => setPressed(true)],
|
||||
[1700, () => (setPressed(false), setStep(0))],
|
||||
[2700, () => setStep(1)],
|
||||
[3700, () => setStep(2)],
|
||||
[4700, () => setStep(3)],
|
||||
[5800, () => setStep(4)]
|
||||
]);
|
||||
const live = () => step() === 4;
|
||||
const working = () => step() >= 0 && step() < 4;
|
||||
const chip = () =>
|
||||
live()
|
||||
? { color: STATUS.ready, label: "Live" }
|
||||
: working()
|
||||
? { color: "rgba(255,255,255,0.55)", label: "Setting up" }
|
||||
: { color: "rgba(255,255,255,0.35)", label: "Not set up" };
|
||||
|
||||
return (
|
||||
<div class="relative mx-auto w-full max-w-[360px] space-y-3" role="img" aria-label="One-click remote setup preview for an authorized Tailscale host">
|
||||
{/* the host's card in Settings ▸ Agents ▸ Remote hosts */}
|
||||
<ModuleCard>
|
||||
<div class="space-y-2 text-left">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[13px] font-semibold text-white">gpu-rig</p>
|
||||
<p class="truncate text-[11px] text-white/45">tailnet peer</p>
|
||||
</div>
|
||||
<Show when={working()}>
|
||||
<span class="h-3 w-3 animate-spin rounded-full border-[1.5px] border-white/25 border-t-white/80" />
|
||||
</Show>
|
||||
<span class="flex items-center gap-1.5 text-[10.5px] font-medium text-white/85">
|
||||
<span
|
||||
class="h-[7px] w-[7px] rounded-full transition-colors duration-300"
|
||||
style={{ background: chip().color }}
|
||||
/>
|
||||
{chip().label}
|
||||
</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="min-h-8 text-[11px] text-white/50">
|
||||
{working() ? SETUP_STEPS[step()] : " "}
|
||||
</p>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
|
||||
{/* The host name stays visible in the session badge. */}
|
||||
<div
|
||||
class="transition-all duration-500"
|
||||
style={{
|
||||
opacity: live() ? 1 : 0,
|
||||
transform: live() ? "translateY(0)" : "translateY(6px)"
|
||||
}}
|
||||
>
|
||||
<SessionRowMock
|
||||
title="trainer · fp16"
|
||||
prompt="benchmark fp16"
|
||||
state="Running python bench.py…"
|
||||
status="running"
|
||||
agent={CODEX}
|
||||
host="gpu-rig"
|
||||
age="now"
|
||||
/>
|
||||
</div>
|
||||
<FakeCursor
|
||||
phase={pressed() ? "press" : step() === -1 ? "idle" : "live"}
|
||||
at={{ x: 88, y: 16 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Capture: the module, and the Wings while a recording runs ────── */
|
||||
|
||||
/** CapturePalette.recording and the NookPalette paper/ink pair. */
|
||||
const RECORDING_RED = "rgb(242,69,69)";
|
||||
const PAPER = "#f1ead9";
|
||||
const INK = "#0d0d0f";
|
||||
|
||||
function CaptureChip(props: {
|
||||
glyph: GlyphName;
|
||||
label: string;
|
||||
menu?: boolean;
|
||||
pressed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded-full py-1.5 text-[11px] font-medium whitespace-nowrap text-white transition-transform duration-150"
|
||||
style={{
|
||||
background: props.pressed ? "rgba(255,255,255,0.16)" : "rgba(255,255,255,0.08)",
|
||||
transform: props.pressed ? "scale(0.94)" : "scale(1)"
|
||||
}}
|
||||
>
|
||||
<Glyph name={props.glyph} size={11} />
|
||||
{props.label}
|
||||
<Show when={props.menu}>
|
||||
<Glyph name="chevron" size={8} class="opacity-50" />
|
||||
</Show>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** CaptureAudioToggles: paper capsule when on, faint when off. */
|
||||
function AudioToggles(props: { system: boolean; mic: boolean }) {
|
||||
const toggle = (on: boolean, glyph: GlyphName, label: string) => (
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full px-2 py-1 text-[10.5px] font-medium whitespace-nowrap transition-colors duration-300"
|
||||
style={{
|
||||
background: on ? PAPER : "rgba(255,255,255,0.08)",
|
||||
color: on ? INK : "rgba(255,255,255,0.7)"
|
||||
}}
|
||||
>
|
||||
<Glyph name={glyph} size={10} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div class="flex gap-1.5">
|
||||
{toggle(props.system, props.system ? "speaker" : "speakerOff", "System audio")}
|
||||
{toggle(props.mic, props.mic ? "mic" : "micOff", "Microphone")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowLabel(props: { glyph: GlyphName; label: string }) {
|
||||
return (
|
||||
<p class="flex items-center gap-1 text-[11px] font-semibold text-white/50">
|
||||
<Glyph name={props.glyph} size={11} />
|
||||
{props.label}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CaptureDemo() {
|
||||
const [phase, setPhase] = createSignal<"idle" | "press" | "rec" | "paused" | "saved">("idle");
|
||||
const [secs, setSecs] = createSignal(0);
|
||||
const [mic, setMic] = createSignal(true);
|
||||
const [wingPress, setWingPress] = createSignal<"" | "mic" | "pause" | "stop">("");
|
||||
|
||||
useDemoLoop(12400, [
|
||||
[0, () => (setPhase("idle"), setSecs(0), setMic(true), setWingPress(""))],
|
||||
[1500, () => setPhase("press")],
|
||||
[1800, () => setPhase("rec")],
|
||||
...[1, 2, 3, 4, 5].map((s) => [1800 + s * 1000, () => setSecs(s)] as [number, () => void]),
|
||||
[4300, () => setWingPress("mic")],
|
||||
[4550, () => (setMic(false), setWingPress(""))],
|
||||
[7100, () => setWingPress("pause")],
|
||||
[7350, () => (setPhase("paused"), setWingPress(""))],
|
||||
[9000, () => setWingPress("stop")],
|
||||
[9250, () => (setPhase("saved"), setWingPress(""))]
|
||||
]);
|
||||
|
||||
const recording = () => phase() === "rec" || phase() === "paused";
|
||||
const paused = () => phase() === "paused";
|
||||
/* Island row is drawn at half size; Wing controls are as tall as it. */
|
||||
const wingWidth = 2 * 60 + 12;
|
||||
const cursor = () => {
|
||||
switch (wingPress()) {
|
||||
case "mic":
|
||||
return { phase: "press" as const, at: { x: 21.8, y: 7 } };
|
||||
case "pause":
|
||||
return { phase: "press" as const, at: { x: 78.2, y: 7 } };
|
||||
case "stop":
|
||||
return { phase: "press" as const, at: { x: 86.5, y: 7 } };
|
||||
}
|
||||
if (phase() === "idle" || phase() === "press")
|
||||
return { phase: phase() === "press" ? ("press" as const) : ("idle" as const), at: { x: 50, y: 58 } };
|
||||
if (phase() === "saved") return { phase: "live" as const, at: { x: 86.5, y: 7 } };
|
||||
return { phase: "idle" as const, at: { x: 21.8, y: 30 } };
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="relative mx-auto w-full max-w-[360px]">
|
||||
{/* the Compact island, flanked by the Wings while recording */}
|
||||
<div class="relative h-[30px]">
|
||||
<div
|
||||
class="absolute top-0 left-1/2 flex items-start"
|
||||
style={{ transform: "translateX(-50%) scale(0.5)", "transform-origin": "top center" }}
|
||||
>
|
||||
<div
|
||||
class="flex justify-end overflow-hidden transition-all duration-500"
|
||||
style={{ width: recording() ? `${wingWidth}px` : "0px", opacity: recording() ? 1 : 0 }}
|
||||
>
|
||||
<WingCapsule
|
||||
height={60}
|
||||
controls={[
|
||||
{ glyph: "speaker" },
|
||||
{ glyph: mic() ? "mic" : "micOff", on: mic(), pressed: wingPress() === "mic" }
|
||||
]}
|
||||
/>
|
||||
<span class="w-3 shrink-0" />
|
||||
</div>
|
||||
<IslandPill
|
||||
campfire="running"
|
||||
right={<AgentDotGrid states={["running", "ready", "running"]} />}
|
||||
/>
|
||||
<div
|
||||
class="flex overflow-hidden transition-all duration-500"
|
||||
style={{ width: recording() ? `${wingWidth}px` : "0px", opacity: recording() ? 1 : 0 }}
|
||||
>
|
||||
<span class="w-3 shrink-0" />
|
||||
<WingCapsule
|
||||
height={60}
|
||||
controls={[
|
||||
{ glyph: paused() ? "play" : "pause", pressed: wingPress() === "pause" },
|
||||
{ glyph: "stop", pressed: wingPress() === "stop" }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* the Capture module */}
|
||||
<ModuleCard class="mt-5">
|
||||
<div class="min-h-[150px] text-left">
|
||||
<Show
|
||||
when={recording()}
|
||||
fallback={
|
||||
<div class="space-y-2.5">
|
||||
<div class="space-y-1.5">
|
||||
<RowLabel glyph="viewfinder" label="Screenshot" />
|
||||
<div class="flex gap-1.5">
|
||||
<CaptureChip glyph="dashed" label="Area" />
|
||||
<CaptureChip glyph="display" label="Screen" />
|
||||
<CaptureChip glyph="window" label="App" menu />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<RowLabel glyph="record" label="Record" />
|
||||
<div class="flex gap-1.5">
|
||||
<CaptureChip glyph="dashed" label="Area" />
|
||||
<CaptureChip glyph="display" label="Screen" pressed={phase() === "press"} />
|
||||
<CaptureChip glyph="window" label="App" menu />
|
||||
</div>
|
||||
<AudioToggles system mic={mic()} />
|
||||
</div>
|
||||
<Show when={phase() === "saved"}>
|
||||
<div class="flex items-center gap-1.5 text-[10.5px]">
|
||||
<Glyph name="film" size={11} class="text-white/50" />
|
||||
<span class="min-w-0 flex-1 truncate text-white/85">
|
||||
Screen Recording 2026-10-13 at 9.41.12.mov
|
||||
</span>
|
||||
<span class="shrink-0 text-white/50">Show in Finder</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full transition-colors"
|
||||
style={{ background: paused() ? "rgba(255,255,255,0.4)" : RECORDING_RED }}
|
||||
/>
|
||||
<span
|
||||
class="font-mono text-[22px] font-semibold transition-colors"
|
||||
style={{ color: paused() ? "rgba(255,255,255,0.5)" : "white" }}
|
||||
>
|
||||
0:0{secs()}
|
||||
</span>
|
||||
<span class="ml-auto font-mono text-[10px] font-medium text-white/50">
|
||||
{paused() ? "Paused" : "Recording"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<CaptureChip glyph={paused() ? "play" : "pause"} label={paused() ? "Resume" : "Pause"} />
|
||||
<CaptureChip glyph="stop" label="Stop" />
|
||||
</div>
|
||||
<AudioToggles system mic={mic()} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</ModuleCard>
|
||||
|
||||
{/* cursor plotted on a fixed 360 px frame centered over the demo */}
|
||||
<div class="pointer-events-none absolute inset-y-0 left-1/2 w-[360px] -translate-x-1/2">
|
||||
<FakeCursor phase={cursor().phase} at={cursor().at} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Floating actions bloom around the open island ────────────────── */
|
||||
|
||||
/* Tiny monochrome glyphs for the action kinds the app offers. */
|
||||
const GLYPH: Record<string, () => JSX.Element> = {
|
||||
leaf: () => (
|
||||
<path d="M3 13C3 7 7 3 13 3c0 6-4 10-10 10Zm0 0 6-6" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" />
|
||||
),
|
||||
bolt: () => <path d="M9 1 3 9h4l-1 6 6-8H8l1-6Z" fill="currentColor" />,
|
||||
timer: () => (
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||
<circle cx="8" cy="9" r="5.5" />
|
||||
<path d="M8 9V6M6.5 1.5h3" />
|
||||
</g>
|
||||
),
|
||||
speaker: () => (
|
||||
<g fill="currentColor">
|
||||
<path d="M2 6h3l4-3v10l-4-3H2Z" />
|
||||
<path d="M11 5.5a3.5 3.5 0 0 1 0 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
|
||||
</g>
|
||||
),
|
||||
shell: () => (
|
||||
<path d="m3 5 3 3-3 3m5 0h5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
),
|
||||
stack: () => (
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round">
|
||||
<path d="m8 2 6 3-6 3-6-3 6-3Z" />
|
||||
<path d="m2 8.5 6 3 6-3M2 11.5l6 3 6-3" />
|
||||
</g>
|
||||
),
|
||||
link: () => (
|
||||
<path d="M7 9a3 3 0 0 0 4 0l2-2a3 3 0 0 0-4-4l-1 1m1 3a3 3 0 0 0-4 0l-2 2a3 3 0 0 0 4 4l1-1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" />
|
||||
)
|
||||
};
|
||||
|
||||
interface FloatAction {
|
||||
glyph: keyof typeof GLYPH;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/* Positions mirror the app's slots: Left ·1/·2, Right ·1/·2, Bottom ·1–3. */
|
||||
const ACTIONS: FloatAction[] = [
|
||||
{ glyph: "leaf", label: "Low Power Mode on", x: -128, y: -18 },
|
||||
{ glyph: "bolt", label: "Top-up to 100%", x: -128, y: 26 },
|
||||
{ glyph: "speaker", label: "Output → Headphones", x: 128, y: -18 },
|
||||
{ glyph: "stack", label: "Shortcut: Focus Time", x: 128, y: 26 },
|
||||
{ glyph: "timer", label: "Timer · 25 min", x: -50, y: 78 },
|
||||
{ glyph: "shell", label: "$ make deploy", x: 0, y: 78 },
|
||||
{ glyph: "link", label: "Opened dashboard", x: 50, y: 78 }
|
||||
];
|
||||
|
||||
export function FloatingActionsDemo() {
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [pressed, setPressed] = createSignal(-1);
|
||||
const [lit, setLit] = createSignal<number[]>([]);
|
||||
|
||||
useDemoLoop(9600, [
|
||||
[0, () => (setOpen(false), setPressed(-1), setLit([]))],
|
||||
[400, () => setOpen(true)],
|
||||
[2200, () => setPressed(0)],
|
||||
[2500, () => setLit([0])],
|
||||
[4000, () => setPressed(4)],
|
||||
[4300, () => setLit([0, 4])],
|
||||
[5800, () => setPressed(2)],
|
||||
[6100, () => setLit([0, 4, 2])],
|
||||
[8800, () => (setOpen(false), setPressed(-1))]
|
||||
]);
|
||||
|
||||
const lastLit = () => lit()[lit().length - 1];
|
||||
|
||||
return (
|
||||
<div class="relative mx-auto flex h-[240px] w-full max-w-[340px] items-start justify-center pt-6">
|
||||
{/* the expanded island */}
|
||||
<div
|
||||
class="relative z-10 rounded-b-[18px] bg-black transition-all duration-500"
|
||||
style={{
|
||||
width: open() ? "190px" : "120px",
|
||||
height: open() ? "96px" : "26px",
|
||||
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)",
|
||||
"box-shadow": `inset 0 0 0 1px ${CARD_STROKE}`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="space-y-1.5 p-3 transition-opacity duration-300"
|
||||
style={{ opacity: open() ? 1 : 0 }}
|
||||
>
|
||||
<div class="h-1.5 w-2/3 rounded bg-white/20" />
|
||||
<div class="h-1.5 w-1/2 rounded bg-white/10" />
|
||||
<div class="h-1.5 w-3/4 rounded" style={{ background: `${CALM}66` }} />
|
||||
</div>
|
||||
<For each={ACTIONS}>
|
||||
{(a, i) => (
|
||||
<span
|
||||
class="absolute flex h-8 w-8 items-center justify-center rounded-full transition-all"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: open()
|
||||
? `translate(calc(-50% + ${a.x}px), calc(-50% + ${a.y - 6}px)) scale(${pressed() === i() ? 0.82 : 1})`
|
||||
: "translate(-50%, -50%) scale(0.2)",
|
||||
opacity: open() ? 1 : 0,
|
||||
background: lit().includes(i()) ? ACCENT : "#1c1c20",
|
||||
color: lit().includes(i()) ? "white" : "rgba(255,255,255,0.7)",
|
||||
"box-shadow": `inset 0 0 0 1px rgba(255,255,255,0.12), 0 4px 14px rgba(0,0,0,0.5)`,
|
||||
"transition-duration": "520ms",
|
||||
"transition-delay": open() ? `${i() * 45}ms` : `${(6 - i()) * 25}ms`,
|
||||
"transition-timing-function": "cubic-bezier(0.34,1.56,0.64,1)"
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 16">
|
||||
{GLYPH[a.glyph]()}
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* what the last press did */}
|
||||
<div
|
||||
class="absolute bottom-1 left-1/2 -translate-x-1/2 rounded-full px-3 py-1 text-[10px] font-semibold whitespace-nowrap text-white transition-all duration-300"
|
||||
style={{
|
||||
background: "rgba(72,151,178,0.22)",
|
||||
opacity: lastLit() !== undefined && open() ? 1 : 0
|
||||
}}
|
||||
>
|
||||
{lastLit() !== undefined ? ACTIONS[lastLit()].label : " "}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import Button from "~/components/ui/Button";
|
||||
import Input from "~/components/ui/Input";
|
||||
import { STATUS, ACCENT } from "./IslandMock";
|
||||
import { useDemoLoop } from "./useDemoLoop";
|
||||
import { betaRequestPayload } from "./betaRequest";
|
||||
|
||||
/**
|
||||
* iPhone + Apple Watch companion, in TestFlight beta. The request form
|
||||
* mirrors Settings ▸ Phone's BetaAccessCard in the app: it sends only the
|
||||
* email, over the same `/api/the-nook/report` pipeline (see betaRequest.ts).
|
||||
*/
|
||||
|
||||
type RequestState = "idle" | "sending" | "sent" | "limited" | "failed";
|
||||
|
||||
function requestBetaAccess(email: string): Promise<Response> {
|
||||
return fetch("/api/the-nook/report", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(betaRequestPayload(email))
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Demo: a request lands on the phone, a question on the wrist ─── */
|
||||
|
||||
function CompanionDemo() {
|
||||
const [phase, setPhase] = createSignal<
|
||||
"quiet" | "ask" | "allowed" | "question" | "answered"
|
||||
>("quiet");
|
||||
useDemoLoop(11000, [
|
||||
[0, () => setPhase("quiet")],
|
||||
[900, () => setPhase("ask")],
|
||||
[3600, () => setPhase("allowed")],
|
||||
[5400, () => setPhase("question")],
|
||||
[8200, () => setPhase("answered")]
|
||||
]);
|
||||
|
||||
const askVisible = () => phase() !== "quiet";
|
||||
const allowed = () => phase() !== "quiet" && phase() !== "ask";
|
||||
const working = () => (allowed() ? 3 : 2);
|
||||
const waiting = () => (phase() === "ask" ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div class="flex items-end justify-center gap-5">
|
||||
{/* iPhone */}
|
||||
<div
|
||||
class="relative h-[300px] w-[150px] overflow-hidden rounded-[28px] p-2"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(120% 80% at 30% 0%, #2b4a6b 0%, #191b2e 60%, #0d0d0f 100%)",
|
||||
"box-shadow":
|
||||
"inset 0 0 0 1.5px rgba(255,255,255,0.18), 0 20px 50px rgba(0,0,0,0.5)"
|
||||
}}
|
||||
>
|
||||
<div class="mx-auto mt-1 h-[16px] w-[52px] rounded-full bg-black" />
|
||||
<p class="mt-3 text-center text-[30px] leading-none font-semibold text-white/90">
|
||||
9:41
|
||||
</p>
|
||||
{/* Lock Screen widget: what the numbers count */}
|
||||
<div class="mt-2 flex justify-center gap-1">
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[8px] font-semibold whitespace-nowrap text-white/85"
|
||||
style={{ background: "rgba(255,255,255,0.12)" }}
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-[1px]" style={{ background: STATUS.running }} />
|
||||
{working()} working
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[8px] font-semibold whitespace-nowrap text-white/85"
|
||||
style={{ background: "rgba(255,255,255,0.12)" }}
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-[1px]"
|
||||
style={{ background: waiting() ? STATUS.attention : STATUS.idle }}
|
||||
/>
|
||||
{waiting()} waiting
|
||||
</span>
|
||||
</div>
|
||||
{/* notification */}
|
||||
<div
|
||||
class="absolute inset-x-2 top-[118px] rounded-2xl p-2 text-left transition-all duration-500"
|
||||
style={{
|
||||
background: "rgba(40,40,46,0.88)",
|
||||
"backdrop-filter": "blur(8px)",
|
||||
opacity: askVisible() ? 1 : 0,
|
||||
transform: askVisible() ? "translateY(0)" : "translateY(-14px)"
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<img src="/nook/icon.png" alt="" class="h-3 w-3 rounded-[3px]" />
|
||||
<span class="text-[7.5px] font-semibold tracking-wide text-white/50 uppercase">
|
||||
The Nook · now
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-[9.5px] font-semibold text-white">
|
||||
{allowed() ? "Allowed ✓" : "Claude Code · the-nook"}
|
||||
</p>
|
||||
<p class="text-[9px] leading-snug text-white/60">
|
||||
{allowed() ? "Agent running." : "Run Bash: swift test"}
|
||||
</p>
|
||||
<Show when={!allowed()}>
|
||||
<div class="mt-1.5 flex gap-1">
|
||||
<span
|
||||
class="flex-1 rounded-md py-0.5 text-center text-[8.5px] font-semibold text-white"
|
||||
style={{ background: ACCENT }}
|
||||
>
|
||||
Allow
|
||||
</span>
|
||||
<span class="flex-1 rounded-md bg-white/10 py-0.5 text-center text-[8.5px] font-semibold text-white/80">
|
||||
Deny
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apple Watch */}
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="h-3 w-[64px] rounded-t-md bg-white/10" />
|
||||
<div
|
||||
class="relative h-[118px] w-[98px] overflow-hidden rounded-[26px] bg-black p-2.5"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"inset 0 0 0 2px rgba(255,255,255,0.16), 0 14px 34px rgba(0,0,0,0.5)",
|
||||
transform: phase() === "question" ? "rotate(-2deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.15s"
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={phase() === "question" || phase() === "answered"}
|
||||
fallback={
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<span class="font-mono text-[18px] font-semibold text-white/90">
|
||||
{working()}
|
||||
</span>
|
||||
<span class="text-[8px] text-white/50">agents working</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p class="text-[8px] font-semibold text-white/50">Codex asks</p>
|
||||
<p class="mt-0.5 text-[9.5px] leading-snug font-semibold text-white">
|
||||
Deploy to staging?
|
||||
</p>
|
||||
<div class="mt-2 space-y-1">
|
||||
<For each={["Yes", "Not yet"]}>
|
||||
{(option, i) => (
|
||||
<span
|
||||
class="block rounded-full py-1 text-center text-[8.5px] font-semibold transition-colors duration-300"
|
||||
style={{
|
||||
background:
|
||||
phase() === "answered" && i() === 0
|
||||
? STATUS.ready
|
||||
: "rgba(255,255,255,0.12)",
|
||||
color: "white"
|
||||
}}
|
||||
>
|
||||
{phase() === "answered" && i() === 0 ? "Sent ✓" : option}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="h-3 w-[64px] rounded-b-md bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PhoneBeta(props: { brandColor: string }) {
|
||||
const [email, setEmail] = createSignal("");
|
||||
const [state, setState] = createSignal<RequestState>("idle");
|
||||
|
||||
const submit = async () => {
|
||||
const contact = email().trim();
|
||||
if (!contact || state() === "sending") return;
|
||||
setState("sending");
|
||||
try {
|
||||
const res = await requestBetaAccess(contact);
|
||||
if (res.status === 429) return setState("limited");
|
||||
if (!res.ok) return setState("failed");
|
||||
setEmail("");
|
||||
setState("sent");
|
||||
} catch {
|
||||
setState("failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class="bg-base relative z-20 px-4 py-20 md:px-8" id="iphone">
|
||||
<div class="mx-auto grid max-w-5xl grid-cols-1 items-center gap-12 md:grid-cols-2">
|
||||
<div>
|
||||
<span class="border-overlay0 bg-surface0 text-subtext0 mb-4 inline-block rounded-full border px-2.5 py-0.5 text-[11px] font-semibold tracking-wide">
|
||||
TestFlight beta
|
||||
</span>
|
||||
<h2 class="text-text mb-3 text-3xl font-bold tracking-tight">
|
||||
Use The Nook on iPhone and Apple Watch
|
||||
</h2>
|
||||
<p class="text-subtext0 mb-5 leading-relaxed">
|
||||
The companion app is in TestFlight beta. Check your agents,
|
||||
respond to permission requests, and answer questions when you're
|
||||
away from your Mac.
|
||||
</p>
|
||||
<ul class="mb-8 space-y-2.5">
|
||||
<For
|
||||
each={[
|
||||
"Allow, deny, answer, and reply from iPhone or Apple Watch",
|
||||
"Connects over an end-to-end encrypted relay, your tailnet, or your LAN",
|
||||
"Agent status widgets for your Home Screen, Lock Screen, and watch face",
|
||||
"Mute notifications for a while, or while you're active at the Mac"
|
||||
]}
|
||||
>
|
||||
{(bullet) => (
|
||||
<li class="text-subtext1 flex gap-2.5 text-sm">
|
||||
<span
|
||||
class="mt-[7px] inline-block h-1.5 w-1.5 shrink-0 rounded-[2px]"
|
||||
style={{ background: ACCENT }}
|
||||
/>
|
||||
{bullet}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
|
||||
<div class="border-overlay0 bg-surface0 rounded-xl border p-6">
|
||||
<h3 class="text-text mb-1 text-lg font-bold">Request beta access</h3>
|
||||
<p class="text-subtext0 mb-4 text-sm">
|
||||
Leave your email to get a TestFlight invite. We only receive your
|
||||
email address.
|
||||
</p>
|
||||
<form
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-end"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
label="Email"
|
||||
containerClass="flex-1"
|
||||
disabled={state() === "sending"}
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
color={props.brandColor}
|
||||
loading={state() === "sending"}
|
||||
>
|
||||
Request access
|
||||
</Button>
|
||||
</form>
|
||||
<Show when={state() === "sent"}>
|
||||
<p class="text-subtext1 mt-4 text-sm">
|
||||
Thanks. We'll email you a TestFlight invite.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={state() === "limited"}>
|
||||
<p class="mt-4 text-sm text-red-500">
|
||||
Too many requests from this network. Try again later.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={state() === "failed"}>
|
||||
<p class="mt-4 text-sm text-red-500">
|
||||
Couldn't send the request. Check your connection and try again.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-2xl px-6 py-10"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(70% 90% at 50% 0%, rgba(72,151,178,0.12) 0%, transparent 70%) #0d0d0f"
|
||||
}}
|
||||
>
|
||||
<CompanionDemo />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* The iPhone beta-access request the landing page posts to
|
||||
* `/api/the-nook/report` — the same title the app's Settings ▸ Phone card
|
||||
* sends, so requests from both land together. Email only, no device facts.
|
||||
*/
|
||||
export function betaRequestPayload(email: string) {
|
||||
return {
|
||||
title: "iPhone app beta access request",
|
||||
description: "Beta access requested from nook.freno.me.",
|
||||
contact: email
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { onCleanup, onMount } from "solid-js";
|
||||
|
||||
/**
|
||||
* Drives a looping landing-page demo: each `[atMs, step]` pair fires at its
|
||||
* offset, and the whole schedule restarts every `periodMs`. Timers are torn
|
||||
* down with the owning component.
|
||||
*/
|
||||
export function useDemoLoop(
|
||||
periodMs: number,
|
||||
steps: [atMs: number, step: () => void][]
|
||||
): void {
|
||||
onMount(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const run = () => {
|
||||
timers.length = 0;
|
||||
for (const [at, step] of steps) {
|
||||
if (at === 0) step();
|
||||
else timers.push(setTimeout(step, at));
|
||||
}
|
||||
};
|
||||
run();
|
||||
const interval = setInterval(run, periodMs);
|
||||
onCleanup(() => {
|
||||
clearInterval(interval);
|
||||
timers.forEach(clearTimeout);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,15 +10,12 @@
|
||||
* - `title` → `props.title + site.titleSuffix`
|
||||
* - `canonical` → explicit `props.canonical` override wins; otherwise
|
||||
* `https://${site.domain}${pathname}` where `pathname` is the *public*
|
||||
* browser path. Subdomain routing is host-aware (root routes dispatch by
|
||||
* `useSite()`, see `src/routes/index.tsx`/`privacy.tsx`/`deletion.tsx`),
|
||||
* so `useLocation()` already reports the public path. The defensive
|
||||
* prefix-strip below also handles any legacy prefixed form, so the canonical
|
||||
* is always the public URL.
|
||||
* browser path. Because the subdomain prefix (`/lineage`, `/nessa`, …) is
|
||||
* an internal-only rewrite (applied by `vercel.json` host rewrites or, in
|
||||
* their absence, by `src/middleware.ts`), `useLocation()` reports the
|
||||
* prefixed path (`/lineage/privacy`) and we strip the prefix back off so
|
||||
* the canonical reflects the public URL (`https://lineage.freno.me/privacy`).
|
||||
* - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`.
|
||||
* Either way it is resolved to an **absolute** URL against the site's
|
||||
* domain — OG consumers (X, Slack, Discord, iMessage) require an absolute
|
||||
* URL, and a site-relative path in `ogDefaultImage` would 404 otherwise.
|
||||
* - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall
|
||||
* back to the base title (no suffix) / description (existing behavior).
|
||||
*/
|
||||
@@ -52,20 +49,6 @@ export interface ResolvedPageHeadMeta {
|
||||
ogImage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an OpenGraph image reference to an absolute URL.
|
||||
*
|
||||
* OG consumers (X, Slack, Discord, LinkedIn, iMessage) require an absolute
|
||||
* URL; `ogDefaultImage` values are site-relative paths (`/nook/og.png`), so
|
||||
* they are joined to the site origin here. Already-absolute references
|
||||
* (`https:`, `data:`, protocol-relative `//`) pass through untouched.
|
||||
*/
|
||||
function absolutizeOgImage(image: string, domain: string): string {
|
||||
if (image.startsWith("//")) return `https:${image}`;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(image)) return image;
|
||||
return `https://${domain}${image.startsWith("/") ? "" : "/"}${image}`;
|
||||
}
|
||||
|
||||
export function resolvePageHeadMeta(
|
||||
props: PageHeadProps,
|
||||
site: Site,
|
||||
@@ -73,11 +56,13 @@ export function resolvePageHeadMeta(
|
||||
): ResolvedPageHeadMeta {
|
||||
const title = `${props.title}${site.titleSuffix}`;
|
||||
/**
|
||||
* The canonical URL is the *public* browser URL, never any internal route
|
||||
* prefix. Subdomain routing is host-aware (root routes dispatch by
|
||||
* `useSite()`), so `useLocation()` returns the public path. The strip below
|
||||
* is a defensive no-op for the public path and still yields the canonical
|
||||
* `https://lineage.freno.me/privacy` if a prefixed form ever appears.
|
||||
* The canonical URL is the *public* browser URL, never the internal route
|
||||
* prefix. SolidStart's file router is host-blind, so subdomain routes live
|
||||
* under `src/routes/<prefix>/*` and are served either by `vercel.json` host
|
||||
* rewrites OR by `src/middleware.ts` (the in-app host rewrite). Both append
|
||||
* the prefix to the internal request path (`/privacy` → `/lineage/privacy`),
|
||||
* so `useLocation()` reports the prefixed path — which we strip back off so
|
||||
* the canonical stays `https://lineage.freno.me/privacy`.
|
||||
*/
|
||||
const publicPath =
|
||||
site.baseRoutePrefix &&
|
||||
@@ -88,10 +73,7 @@ export function resolvePageHeadMeta(
|
||||
const canonical = props.canonical ?? `https://${site.domain}${publicPath}`;
|
||||
const ogTitle = props.ogTitle ?? props.title;
|
||||
const ogDescription = props.ogDescription ?? props.description;
|
||||
const ogImage = absolutizeOgImage(
|
||||
props.ogImage ?? site.ogDefaultImage,
|
||||
site.domain
|
||||
);
|
||||
const ogImage = props.ogImage ?? site.ogDefaultImage;
|
||||
|
||||
return {
|
||||
title,
|
||||
|
||||