Compare commits
148 Commits
ede56e5bbd
...
faq/from-o
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd91b0d3ef | ||
|
|
db3e695ecf | ||
|
|
4d6550f0d1 | ||
|
|
bb6cf27821 | ||
|
|
e46c0a2677 | ||
|
|
2c917e63e8 | ||
|
|
871e47dee9 | ||
|
|
f20389e64e | ||
|
|
534c1ccd86 | ||
|
|
4a18beeaff | ||
|
|
3d41d6cd45 | ||
|
|
477bb807fc | ||
|
|
7ea654069c | ||
|
|
c1c498e37f | ||
| 134f41eac4 | |||
| 881190d1d4 | |||
| f492132bf2 | |||
| a524f23be2 | |||
| 0bf15e528d | |||
| a48326fd1d | |||
| ac63057ef7 | |||
| f6d6d442de | |||
| 14a61fde9b | |||
| b4e68765fe | |||
| dabfeada3b | |||
| 9508aee8e1 | |||
| 1a182e53fe | |||
| 6898186df5 | |||
| 9ff095a82c | |||
| be90b3d068 | |||
| cbe56390f8 | |||
| 26c37c1862 | |||
| 7108f77bf9 | |||
| 687df016a6 | |||
| 92af52dfc9 | |||
| 53f1413966 | |||
| 6f41436591 | |||
| 0b128f6639 | |||
| 37a39b59fe | |||
| ba6b478320 | |||
| 35986239c1 | |||
| b41cf83879 | |||
| f66fd43f62 | |||
| 421fb946fc | |||
| dc959d971b | |||
| 44bf21a868 | |||
| dd63da30c4 | |||
| 2fb5097b85 | |||
| 96e401ecbb | |||
| 3fe3516b9e | |||
| bef8f8414b | |||
| f3e9cdf0bd | |||
| 7ecc0f68d7 | |||
| 00a26a9e49 | |||
| 163c68fcb8 | |||
| 3149eba196 | |||
| bc65789431 | |||
| 21f1f07a99 | |||
| 23f86681af | |||
| 8492821f75 | |||
| 17bb43851e | |||
| fd68efdb78 | |||
| 1ee61d8fe4 | |||
| 6c024342ba | |||
| 9860213760 | |||
| cb584f8b68 | |||
| 8c5fe25c2b | |||
| 22f40b2284 | |||
| b10a81068d | |||
| 6d3315c71f | |||
| 20944bf46e | |||
| a161c96b05 | |||
| 082e503d4f | |||
| 0031055315 | |||
| 9ae1b8a2e5 | |||
| e4a9be4899 | |||
| 898c891bd5 | |||
| 33ca9213f2 | |||
| 34db684fe1 | |||
| 2f1b5f35c6 | |||
| 44b6e7595c | |||
| 5ddbfbc429 | |||
| ad5221dc35 | |||
| 5eecb8e7d1 | |||
| c66cd6d456 | |||
| 4b663c014a | |||
| 9e285570e1 | |||
| a876cee6ec | |||
| 84e4c2203e | |||
| f2fc6a5d1a | |||
| 8e33af43a5 | |||
| a6ef4d2955 | |||
| e7b32d6566 | |||
| e55a28d249 | |||
| 7fb38f37a0 | |||
| 06c39b8813 | |||
| 59d129b46b | |||
| d5794f6e34 | |||
| a7ad581ed2 | |||
| 6a8c8b7662 | |||
| 5705c5add7 | |||
| f2ff75e6df | |||
| bc907fb990 | |||
| 1cc16313d5 | |||
| 0356bdcaad | |||
| dbf47f3170 | |||
| 6fdbbbbe2b | |||
| d42cfc106b | |||
| dcfa32c5af | |||
| 37ed5170ed | |||
| 650b216dbc | |||
| d82f31990d | |||
| 741a6e26a3 | |||
| 5b9c680b25 | |||
| e9aa987b94 | |||
| d399f0b1d7 | |||
| 77f84c47b1 | |||
| fd486d8af6 | |||
| 8ae42d32bd | |||
| 92890ce259 | |||
| 72d1cfaf3f | |||
| 0385090f32 | |||
| 7287f10c9a | |||
| 7ddbe752a5 | |||
| 1530a18431 | |||
| d4621b6ae2 | |||
| 42757bc93d | |||
| ff956be80f | |||
| e446eb1775 | |||
| 3bb3e80b77 | |||
| 333ea9a28a | |||
| 0c29135bad | |||
| 52174c94dc | |||
| 7dc5166e90 | |||
| 30b2d03c68 | |||
| d48bbc0fc3 | |||
| b7187721db | |||
| fbc8215410 | |||
| 8b6551330f | |||
| 3635133994 | |||
| 4dbd0ac965 | |||
| 67bf77815e | |||
| 9c48bc61c0 | |||
| 6a6feade42 | |||
| 1a60e606de | |||
| e045909acb | |||
| 40411104d6 | |||
| c6e9fccc9a |
88
.env.example
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# freno-dev environment variables — example / template
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Copy this file to `.env` and fill in real values.
|
||||||
|
# `.env` is gitignored and MUST NEVER be committed. Real secret values must
|
||||||
|
# come from your local environment or your team's secret manager — never from
|
||||||
|
# git history. See the root `AGENTS.md` "Secret Management & Rotation" section
|
||||||
|
# and `docs/security/secret-rotation-runbook.md`.
|
||||||
|
#
|
||||||
|
# The schema in `src/env/server.ts` validates PRESENCE + min length for every
|
||||||
|
# variable below. Do not leave production values blank.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Frontend / public (safe to expose to the browser, VITE_* is shipped) ──
|
||||||
|
VITE_DOMAIN="http://localhost:3000"
|
||||||
|
VITE_AWS_BUCKET_STRING="https://example-bucket.s3.amazonaws.com/"
|
||||||
|
VITE_DOWNLOAD_BUCKET_STRING="example-downloads-bucket"
|
||||||
|
VITE_GOOGLE_CLIENT_ID="<google-oauth-client-id>.apps.googleusercontent.com"
|
||||||
|
VITE_GOOGLE_CLIENT_ID_DEV="<google-oauth-client-id-dev>.apps.googleusercontent.com"
|
||||||
|
VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE="<google-oauth-client-id-magicdelve>.apps.googleusercontent.com"
|
||||||
|
VITE_GITHUB_CLIENT_ID="<github-oauth-client-id>"
|
||||||
|
VITE_GITHUB_CLIENT_ID_DEV="<github-oauth-client-id-dev>"
|
||||||
|
VITE_INFILL_ENDPOINT="https://infill.example.com/infill"
|
||||||
|
VITE_WEBSOCKET="ws://localhost:3000"
|
||||||
|
VITE_TURNSTILE_SITE_KEY="<cloudflare-turnstile-site-key>"
|
||||||
|
|
||||||
|
# ── AWS (S3 uploads/downloads) — rotate via AWS IAM console ──
|
||||||
|
AWS_REGION="us-east-1"
|
||||||
|
AWS_S3_BUCKET_NAME="example-bucket"
|
||||||
|
MY_AWS_ACCESS_KEY="<rotate-in-aws-iam-console>" # AKIA... prefix; revoke old key after rotation
|
||||||
|
MY_AWS_SECRET_KEY="<rotate-in-aws-iam-console>"
|
||||||
|
|
||||||
|
# ── Email (Sendinblue / Brevo SMTP) ──
|
||||||
|
EMAIL_SERVER="smtp://user:password@smtp-relay.sendinblue.com:587"
|
||||||
|
EMAIL_FROM="you@example.com"
|
||||||
|
SENDINBLUE_KEY="<rotate-in-brevo-console>"
|
||||||
|
|
||||||
|
# ── Auth / signing secrets (generate with: openssl rand -base64 64) ──
|
||||||
|
JWT_SECRET_KEY="<generate-64-byte-base64>" # web JWT (HS256) signing
|
||||||
|
LINEAGE_JWT_SECRET="<generate-64-byte-base64>" # Lineage game JWT (HS256) signing — isolated from web (p8-005)
|
||||||
|
LINEAGE_OFFLINE_SERIALIZATION_SECRET="<generate-64-byte-base64>" # offline lineage blob signing
|
||||||
|
|
||||||
|
# ── OAuth client secrets — rotate in provider consoles ──
|
||||||
|
GOOGLE_CLIENT_SECRET="<rotate-in-google-cloud-console>" # GOCSPX-...
|
||||||
|
GOOGLE_CLIENT_SECRET_DEV="<rotate-in-google-cloud-console>"
|
||||||
|
GITHUB_CLIENT_SECRET="<rotate-in-github-oauth-apps>"
|
||||||
|
GITHUB_CLIENT_SECRET_DEV="<rotate-in-github-oauth-apps>"
|
||||||
|
|
||||||
|
# ── Cloudflare Turnstile ──
|
||||||
|
TURNSTILE_SECRET_KEY="<rotate-in-cloudflare-dashboard>" # 0x...
|
||||||
|
|
||||||
|
# ── Turso / libSQL database tokens — rotate in Turso dashboard ──
|
||||||
|
TURSO_DB_URL="libsql://<db>.turso.io"
|
||||||
|
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>"
|
||||||
|
|
||||||
|
APPLE_CLIENT_ID_LINEAGE=com...
|
||||||
|
|
||||||
|
# ── Infra / integration tokens ──
|
||||||
|
INFILL_BEARER_TOKEN="<rotate-at-infill-service>"
|
||||||
|
GITEA_URL="https://gitea.example.com"
|
||||||
|
GITEA_TOKEN="<rotate-in-gitea>"
|
||||||
|
GITHUB_API_TOKEN="<rotate-in-github-settings>" # ghp_... / github_pat_...
|
||||||
|
|
||||||
|
# ── Sentry (error tracking + source maps) ──
|
||||||
|
# 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=""
|
||||||
5
.gitignore
vendored
@@ -9,6 +9,7 @@ app.config.timestamp_*.js
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env.bak
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
@@ -30,3 +31,7 @@ perf-results-*.json
|
|||||||
# System Files
|
# System Files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
# pygienium run-state and check artifacts
|
||||||
|
.pygienium/
|
||||||
|
scripts/
|
||||||
|
.cache
|
||||||
|
|||||||
30
AGENTS.md
@@ -1,6 +1,7 @@
|
|||||||
# Agent Guidelines for freno-dev
|
# Agent Guidelines for freno-dev
|
||||||
|
|
||||||
### Tech Stack
|
### Tech Stack
|
||||||
|
|
||||||
- **Framework**: SolidJS with SolidStart (Vinxi)
|
- **Framework**: SolidJS with SolidStart (Vinxi)
|
||||||
- **Routing**: @solidjs/router
|
- **Routing**: @solidjs/router
|
||||||
- **API**: tRPC v10 with Zod validation
|
- **API**: tRPC v10 with Zod validation
|
||||||
@@ -9,20 +10,33 @@
|
|||||||
- **Runtime**: Bun (Node >=22)
|
- **Runtime**: Bun (Node >=22)
|
||||||
- **Deployment**: Vercel preset
|
- **Deployment**: Vercel preset
|
||||||
|
|
||||||
|
## Content Rules
|
||||||
|
|
||||||
|
### No Competitor Mentions
|
||||||
|
|
||||||
|
- **Never mention a competitor's product by name** in any user-facing content (landing pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, etc.).
|
||||||
|
- This includes but is not limited to products like Strava, Garmin, etc.
|
||||||
|
- Describe the product's own merits and positioning on its own terms. Use generic descriptions instead of naming competitors.
|
||||||
|
- This applies to ALL products (Nessa, Lineage, Gaze, InputHalo) and the main site.
|
||||||
|
- If existing content already names a competitor, remove or generalize it when working in that file.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
### Naming Conventions
|
### Naming Conventions
|
||||||
|
|
||||||
- **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`)
|
- **Files/Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`)
|
||||||
- **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`)
|
- **Variables/Functions**: camelCase (e.g., `getUserID`, `displayName`)
|
||||||
- **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`)
|
- **Types/Interfaces**: PascalCase (e.g., `User`, `ButtonProps`)
|
||||||
- **Constants**: camelCase or UPPER_SNAKE_CASE for true constants
|
- **Constants**: camelCase or UPPER_SNAKE_CASE for true constants
|
||||||
|
|
||||||
### Imports
|
### Imports
|
||||||
|
|
||||||
- Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"`
|
- Prefer named imports from solid-js: `import { createSignal, Show, For } from "solid-js"`
|
||||||
- Use `~/*` path alias for src imports: `import { api } from "~/lib/api"`
|
- Use `~/*` path alias for src imports: `import { api } from "~/lib/api"`
|
||||||
- Group imports: external deps → solid-js → local (~/)
|
- Group imports: external deps → solid-js → local (~/)
|
||||||
|
|
||||||
### SolidJS Patterns (NOT React!)
|
### SolidJS Patterns (NOT React!)
|
||||||
|
|
||||||
- **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read
|
- **State**: Use `createSignal()` not `useState`. Always call signals: `count()` to read
|
||||||
- **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array)
|
- **Effects**: Use `createEffect()` not `useEffect`. Auto-tracks dependencies (no array)
|
||||||
- **Conditionals**: Prefer `<Show when={condition()}>` over `&&` or ternary
|
- **Conditionals**: Prefer `<Show when={condition()}>` over `&&` or ternary
|
||||||
@@ -31,6 +45,7 @@
|
|||||||
- **Refs**: Use `let ref` binding or `createSignal()` for reactive refs
|
- **Refs**: Use `let ref` binding or `createSignal()` for reactive refs
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
|
|
||||||
- **Strict mode enabled** - always type function params and returns
|
- **Strict mode enabled** - always type function params and returns
|
||||||
- Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes<T>`
|
- Use interfaces for props: `export interface ButtonProps extends JSX.HTMLAttributes<T>`
|
||||||
- Use `splitProps()` for component prop destructuring
|
- Use `splitProps()` for component prop destructuring
|
||||||
@@ -38,6 +53,7 @@
|
|||||||
- Database types: Cast with `as unknown as User` for SQL results
|
- Database types: Cast with `as unknown as User` for SQL results
|
||||||
|
|
||||||
### API/Server Patterns
|
### API/Server Patterns
|
||||||
|
|
||||||
- **tRPC routers**: Export from `src/server/api/routers/*.ts`
|
- **tRPC routers**: Export from `src/server/api/routers/*.ts`
|
||||||
- **Procedures**: Use `.query()` for reads, `.mutation()` for writes
|
- **Procedures**: Use `.query()` for reads, `.mutation()` for writes
|
||||||
- **Validation**: Use Zod schemas in `.input()` - validate all user input
|
- **Validation**: Use Zod schemas in `.input()` - validate all user input
|
||||||
@@ -46,25 +62,39 @@
|
|||||||
- **Database**: Use `ConnectionFactory()` singleton, parameterized queries only
|
- **Database**: Use `ConnectionFactory()` singleton, parameterized queries only
|
||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
- Use TRPCError with semantic codes on server
|
- Use TRPCError with semantic codes on server
|
||||||
- Validate inputs with Zod schemas before processing
|
- Validate inputs with Zod schemas before processing
|
||||||
- Check auth state before mutations: throw UNAUTHORIZED if missing userId
|
- Check auth state before mutations: throw UNAUTHORIZED if missing userId
|
||||||
- Return structured responses: `{ success: boolean, message?: string }`
|
- Return structured responses: `{ success: boolean, message?: string }`
|
||||||
|
|
||||||
### Comments
|
### Comments
|
||||||
|
|
||||||
- **Minimal comments** - prefer self-documenting code
|
- **Minimal comments** - prefer self-documenting code
|
||||||
- JSDoc for exported functions/components only
|
- JSDoc for exported functions/components only
|
||||||
- Inline comments for non-obvious logic only
|
- Inline comments for non-obvious logic only
|
||||||
|
|
||||||
### File Organization
|
### File Organization
|
||||||
|
|
||||||
- Routes in `src/routes/` (file-based routing)
|
- Routes in `src/routes/` (file-based routing)
|
||||||
- Components in `src/components/` (reusable) or co-located with routes
|
- Components in `src/components/` (reusable) or co-located with routes
|
||||||
- API routers in `src/server/api/routers/`
|
- API routers in `src/server/api/routers/`
|
||||||
- Types in `src/types/` (shared types) or co-located
|
- Types in `src/types/` (shared types) or co-located
|
||||||
- Utils in `src/lib/` or `src/server/utils.ts`
|
- Utils in `src/lib/` or `src/server/utils.ts`
|
||||||
|
|
||||||
|
## Subdomain Routing
|
||||||
|
|
||||||
|
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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
## Key Differences from React
|
## Key Differences from React
|
||||||
|
|
||||||
See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas:
|
See `src/lib/SOLID-PATTERNS.md` for comprehensive React→Solid conversion guide. Key gotchas:
|
||||||
|
|
||||||
- Signals must be called with `()` to read value
|
- Signals must be called with `()` to read value
|
||||||
- `onChange` → `onInput` for real-time input updates
|
- `onChange` → `onInput` for real-time input updates
|
||||||
- `useEffect` → `createEffect` (auto-tracking, no deps array)
|
- `useEffect` → `createEffect` (auto-tracking, no deps array)
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { defineConfig } from "@solidjs/start/config";
|
import { defineConfig } from "@solidjs/start/config";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
|
sentryPlugin({
|
||||||
|
org: "mikefreno",
|
||||||
|
project: "freno-dev",
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
|
telemetry: false
|
||||||
|
})
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: (id) => {
|
manualChunks: (id: string) => {
|
||||||
// Bundle highlight.js and lowlight together
|
// Bundle highlight.js and lowlight together
|
||||||
if (id.includes("highlight.js") || id.includes("lowlight")) {
|
if (id.includes("highlight.js") || id.includes("lowlight")) {
|
||||||
return "highlight";
|
return "highlight";
|
||||||
|
|||||||
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[define]
|
||||||
|
"import.meta.env.SSR" = "true"
|
||||||
246
docs/sparkle-dual-host-support.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Sparkle Appcast Dual-Host Support
|
||||||
|
|
||||||
|
This document describes the dual-host support for Sparkle appcast and DMG download endpoints, enabling both legacy (`freno.me`) and new subdomain (`*.freno.me`) URLs to work with a single code path.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vercel Edge │
|
||||||
|
│ │
|
||||||
|
│ gaze.freno.me/api/Gaze/appcast.xml ───┐ │
|
||||||
|
│ inputhalo.freno.me/api/InputHalo/... ─┼──► /api/(.*) pass-through ──► │
|
||||||
|
│ freno.me/api/Gaze/appcast.xml ────────┘ rewrite │
|
||||||
|
│ ▼ │
|
||||||
|
│ /api/Gaze/appcast.xml │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Node.js Server │
|
||||||
|
│ │
|
||||||
|
│ src/routes/api/Gaze/appcast.xml.ts │
|
||||||
|
│ src/routes/api/InputHalo/appcast.xml.ts │
|
||||||
|
│ src/routes/api/downloads/[filename].ts │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AWS S3 │
|
||||||
|
│ │
|
||||||
|
│ frenomedownloads/api/Gaze/appcast.xml │
|
||||||
|
│ frenomedownloads/api/InputHalo/appcast.xml │
|
||||||
|
│ frenomedownloads/downloads/Gaze-0.7.8.dmg │
|
||||||
|
│ frenomedownloads/downloads/InputHalo-0.5.2.dmg │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL Routing Strategy
|
||||||
|
|
||||||
|
### Pass-Through Rewrite Pattern
|
||||||
|
|
||||||
|
Vercel JSON rewrites route subdomain API requests to the shared `/api/*` route pool:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rewrites": [
|
||||||
|
// API pass-throughs (MUST come before catch-all)
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/api/$1" },
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/api/$1" },
|
||||||
|
|
||||||
|
// Catch-all subdomain rewrites
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" },
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" },
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why pass-through instead of redirect?**
|
||||||
|
- Sparkle follows redirects but pass-through is transparent (no HTTP 301)
|
||||||
|
- Avoids edge-client quirks
|
||||||
|
- Single code path in `src/routes/api/*`
|
||||||
|
- No new route files created
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Appcast XML
|
||||||
|
|
||||||
|
| Product | Legacy URL | New Subdomain URL |
|
||||||
|
|---------|------------|-------------------|
|
||||||
|
| Gaze | `https://freno.me/api/Gaze/appcast.xml` | `https://gaze.freno.me/api/Gaze/appcast.xml` |
|
||||||
|
| InputHalo | `https://freno.me/api/InputHalo/appcast.xml` | `https://inputhalo.freno.me/api/InputHalo/appcast.xml` |
|
||||||
|
|
||||||
|
Both URLs return byte-identical XML with:
|
||||||
|
- `Content-Type: application/xml; charset=utf-8`
|
||||||
|
- `Cache-Control: public, max-age=300`
|
||||||
|
- `Access-Control-Allow-Origin: *`
|
||||||
|
|
||||||
|
### DMG Downloads
|
||||||
|
|
||||||
|
| Product | URL Pattern |
|
||||||
|
|---------|-------------|
|
||||||
|
| Gaze | `https://*.freno.me/api/downloads/Gaze-{version}.dmg` |
|
||||||
|
| InputHalo | `https://*.freno.me/api/downloads/InputHalo-{version}.dmg` |
|
||||||
|
|
||||||
|
Works from all five hosts: `freno.me`, `gaze.freno.me`, `inputhalo.freno.me`, `nessa.freno.me`, `lineage.freno.me`
|
||||||
|
|
||||||
|
## Enclosure URL Strategy
|
||||||
|
|
||||||
|
### Current State (Absolute freno.me URLs)
|
||||||
|
|
||||||
|
Appcast XML in S3 uses absolute URLs for enclosures:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<enclosure url="https://freno.me/api/downloads/Gaze-0.7.8.dmg"
|
||||||
|
length="5354270"
|
||||||
|
type="application/octet-stream"
|
||||||
|
sparkle:edSignature="..."/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Resolves from any host (freno.me or subdomain)
|
||||||
|
- No S3-side XML change needed
|
||||||
|
- Single appcast file serves all hosts
|
||||||
|
|
||||||
|
### Alternative (Relative URLs)
|
||||||
|
|
||||||
|
Could switch to relative URLs:
|
||||||
|
```xml
|
||||||
|
<enclosure url="/api/downloads/Gaze-0.7.8.dmg" .../>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trade-offs:**
|
||||||
|
- Would resolve against the serving host
|
||||||
|
- Requires regenerating appcast with `generate_appcast` / `.manage_sparkle.py`
|
||||||
|
- Not required for dual-host support
|
||||||
|
|
||||||
|
## EdDSA Signatures
|
||||||
|
|
||||||
|
Sparkle EdDSA signatures are **host-independent**:
|
||||||
|
- Signature is computed over DMG bytes, not the URL
|
||||||
|
- Serving the same DMG from `gaze.freno.me` instead of `freno.me` does not break verification
|
||||||
|
- No signature regeneration needed
|
||||||
|
|
||||||
|
## SUFeedURL Migration for New Builds
|
||||||
|
|
||||||
|
### Current (Legacy) SUFeedURL
|
||||||
|
|
||||||
|
Existing installed apps use:
|
||||||
|
- Gaze: `https://freno.me/api/Gaze/appcast.xml`
|
||||||
|
- InputHalo: `https://freno.me/api/InputHalo/appcast.xml`
|
||||||
|
|
||||||
|
These continue to work indefinitely via the pass-through rewrite.
|
||||||
|
|
||||||
|
### New SUFeedURL (For New Builds)
|
||||||
|
|
||||||
|
**Action Required in Swift Repos:**
|
||||||
|
|
||||||
|
#### Gaze (`~/Code/Gaze/`)
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### InputHalo (`~/Code/InputHalo/`)
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Keep old builds on legacy URLs — they continue working via pass-through.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Automated Verification Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all products
|
||||||
|
./scripts/verify-sparkle-dual-host.sh
|
||||||
|
|
||||||
|
# Verify specific product
|
||||||
|
./scripts/verify-sparkle-dual-host.sh Gaze
|
||||||
|
./scripts/verify-sparkle-dual-host.sh InputHalo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
|
||||||
|
#### Appcast Byte-Identical Check
|
||||||
|
```bash
|
||||||
|
# Gaze
|
||||||
|
diff <(curl -s https://gaze.freno.me/api/Gaze/appcast.xml) \
|
||||||
|
<(curl -s https://freno.me/api/Gaze/appcast.xml)
|
||||||
|
# Expected: no output (identical)
|
||||||
|
|
||||||
|
# InputHalo
|
||||||
|
diff <(curl -s https://inputhalo.freno.me/api/InputHalo/appcast.xml) \
|
||||||
|
<(curl -s https://freno.me/api/InputHalo/appcast.xml)
|
||||||
|
# Expected: no output (identical)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DMG Download Check
|
||||||
|
```bash
|
||||||
|
# Gaze
|
||||||
|
curl -sI https://gaze.freno.me/api/downloads/Gaze-0.7.8.dmg | head -1
|
||||||
|
# Expected: HTTP/2 200
|
||||||
|
|
||||||
|
# InputHalo
|
||||||
|
curl -sI https://inputhalo.freno.me/api/downloads/InputHalo-0.5.2.dmg | head -1
|
||||||
|
# Expected: HTTP/2 200
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sparkle Update Check (Native App)
|
||||||
|
1. Set dev build's `SUFeedURL` to subdomain URL
|
||||||
|
2. In app: "Check for Updates..." → Should find new version
|
||||||
|
3. Verify download completes successfully
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Appcast Returns 404
|
||||||
|
- Check S3 bucket: `aws s3 ls s3://frenomedownloads/api/{Product}/`
|
||||||
|
- Verify appcast XML file exists
|
||||||
|
- Check server logs for S3 errors
|
||||||
|
|
||||||
|
### Appcast Returns 500
|
||||||
|
- Check S3 credentials in Vercel environment
|
||||||
|
- Verify bucket policy allows read access
|
||||||
|
- Check server logs for S3 errors
|
||||||
|
|
||||||
|
### Content Differs Between Hosts
|
||||||
|
- Check vercel.json rewrite ordering
|
||||||
|
- Verify `/api/(.*)` pass-throughs come before `/(.*)` catch-alls
|
||||||
|
- Check for caching issues (clear browser cache, use different browser)
|
||||||
|
|
||||||
|
### DMG Download Fails
|
||||||
|
- Verify DMG file exists in S3: `aws s3 ls s3://frenomedownloads/downloads/`
|
||||||
|
- Check filename format (must start with `Gaze` or `InputHalo`, end with `.dmg` or `.delta`)
|
||||||
|
- Check server logs for S3 errors
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `vercel.json` | Host-based rewrites configuration |
|
||||||
|
| `src/routes/api/Gaze/appcast.xml.ts` | Gaze appcast route |
|
||||||
|
| `src/routes/api/InputHalo/appcast.xml.ts` | InputHalo appcast route |
|
||||||
|
| `src/routes/api/downloads/[filename].ts` | DMG download route |
|
||||||
|
| `scripts/verify-sparkle-dual-host.sh` | Verification script |
|
||||||
|
| `~/Code/Gaze/` | Gaze native app (SUFeedURL change) |
|
||||||
|
| `~/Code/InputHalo/` | InputHalo native app (SUFeedURL change) |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `gaze.freno.me/api/Gaze/appcast.xml` returns byte-identical XML to `freno.me/api/Gaze/appcast.xml`
|
||||||
|
- [x] `inputhalo.freno.me/api/InputHalo/appcast.xml` returns byte-identical XML to `freno.me/api/InputHalo/appcast.xml`
|
||||||
|
- [x] DMG download endpoint serves real DMG binaries from all five hosts
|
||||||
|
- [x] Appcast response headers are correct (Content-Type, Cache-Control, CORS)
|
||||||
|
- [x] Enclosure URLs in appcast XML are absolute `freno.me` URLs
|
||||||
|
- [x] Sparkle EdDSA signatures are valid (host-independent)
|
||||||
|
- [ ] Dev build pointed at subdomain SUFeedURL successfully checks for + downloads update
|
||||||
|
- [ ] Existing build on legacy SUFeedURL is unaffected (no regression)
|
||||||
|
- [ ] SUFeedURL change for new builds is documented / filed against Gaze and InputHalo repos
|
||||||
150
docs/sparkle-dual-host-task-summary.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Sparkle Appcast Dual-Host Support - Completion Summary
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make the Sparkle auto-update feed and DMG download endpoints reachable from BOTH the legacy `freno.me/api/*` URLs and the new `*.freno.me/api/*` subdomain URLs, with a single code path.
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
### Step 1: Vercel JSON Rewrite Ordering ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
The `/api/*` pass-through rules are correctly ordered BEFORE the catch-all rewrites:
|
||||||
|
|
||||||
|
```
|
||||||
|
Lines 4-19: /api/(.*) pass-through rules (one per subdomain host)
|
||||||
|
Lines 23-26: /(.*) catch-all subdomain rewrites
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures `gaze.freno.me/api/Gaze/appcast.xml` → `/api/Gaze/appcast.xml` (not `/gaze/api/Gaze/appcast.xml`).
|
||||||
|
|
||||||
|
### Step 2: Appcast Endpoints on freno.me ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
| Endpoint | HTTP Status | Content-Type | Cache-Control | CORS | XML Valid |
|
||||||
|
|----------|-------------|--------------|---------------|------|-----------|
|
||||||
|
| `freno.me/api/Gaze/appcast.xml` | 200 ✓ | application/xml ✓ | max-age=300 ✓ | * ✓ | Valid ✓ |
|
||||||
|
| `freno.me/api/InputHalo/appcast.xml` | 200 ✓ | application/xml ✓ | max-age=300 ✓ | * ✓ | Valid ✓ |
|
||||||
|
|
||||||
|
### Step 3: Subdomain Appcast Endpoints
|
||||||
|
|
||||||
|
**Status: PENDING DNS/Vercel Configuration**
|
||||||
|
|
||||||
|
Subdomain endpoints (`gaze.freno.me`, `inputhalo.freno.me`) will be verified after task 12 DNS/Vercel configuration is complete.
|
||||||
|
|
||||||
|
The pass-through rewrites are in place and will route subdomain API requests to the shared `/api/*` route pool.
|
||||||
|
|
||||||
|
### Step 4: DMG Download Endpoints ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
| Endpoint | HTTP Status | Content-Type | Content-Disposition |
|
||||||
|
|----------|-------------|---------------|---------------------|
|
||||||
|
| `freno.me/api/downloads/Gaze-0.7.8.dmg` | 200 ✓ | apple-diskimage ✓ | attachment ✓ |
|
||||||
|
| `freno.me/api/downloads/InputHalo-0.5.2.dmg` | 200 ✓ | apple-diskimage ✓ | attachment ✓ |
|
||||||
|
|
||||||
|
### Step 5: Enclosure URL Strategy ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
Appcast XML in S3 uses absolute `https://freno.me/api/downloads/*.dmg` URLs:
|
||||||
|
|
||||||
|
- Gaze: `https://freno.me/api/downloads/Gaze-0.7.8.dmg`
|
||||||
|
- InputHalo: `https://freno.me/api/downloads/InputHalo-0.5.2.dmg`
|
||||||
|
|
||||||
|
These resolve from ANY host (freno.me or subdomain) — no S3-side XML change needed.
|
||||||
|
|
||||||
|
### Step 6: DMG Size and Signature Verification ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
| DMG | S3 Size | Appcast Size | Match |
|
||||||
|
|-----|---------|---------------|-------|
|
||||||
|
| Gaze-0.7.8.dmg | 5,354,270 bytes | 5,354,270 bytes | ✓ |
|
||||||
|
| InputHalo-0.5.2.dmg | 4,999,679 bytes | 4,999,679 bytes | ✓ |
|
||||||
|
|
||||||
|
EdDSA signatures are host-independent — serving from subdomain hosts does not invalidate verification.
|
||||||
|
|
||||||
|
### Step 7: Content Byte-Identical Verification ✓
|
||||||
|
|
||||||
|
**Status: VERIFIED**
|
||||||
|
|
||||||
|
Multiple requests to the same endpoints return byte-identical content.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
### 1. Verification Script
|
||||||
|
|
||||||
|
**File:** `scripts/verify-sparkle-dual-host.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify all products
|
||||||
|
./scripts/verify-sparkle-dual-host.sh
|
||||||
|
|
||||||
|
# Verify specific product
|
||||||
|
./scripts/verify-sparkle-dual-host.sh Gaze
|
||||||
|
./scripts/verify-sparkle-dual-host.sh InputHalo
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `docs/sparkle-dual-host-support.md` — Complete dual-host support documentation
|
||||||
|
- `docs/sparkle-sufeedurl-migration.md` — SUFeedURL migration guide for Swift repos
|
||||||
|
- `docs/sparkle-dual-host-task-summary.md` — This file
|
||||||
|
|
||||||
|
### 3. SUFeedURL Migration Documentation
|
||||||
|
|
||||||
|
**Status: Documented for Swift repo owners**
|
||||||
|
|
||||||
|
#### Gaze (`~/Code/Gaze/`)
|
||||||
|
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
#### InputHalo (`~/Code/InputHalo/`)
|
||||||
|
|
||||||
|
Set `SUFeedURL` in Info.plist to:
|
||||||
|
```
|
||||||
|
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Keep old builds on legacy URLs — they continue working via pass-through.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
| Criterion | Status | Evidence |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| `gaze.freno.me/api/Gaze/appcast.xml` returns byte-identical XML to `freno.me/api/Gaze/appcast.xml` | PENDING | Requires DNS/Vercel config |
|
||||||
|
| `inputhalo.freno.me/api/InputHalo/appcast.xml` returns byte-identical XML to `freno.me/api/InputHalo/appcast.xml` | PENDING | Requires DNS/Vercel config |
|
||||||
|
| DMG download endpoint serves real DMG binaries from all five hosts | PENDING | Requires DNS/Vercel config |
|
||||||
|
| Appcast response headers are correct (Content-Type, Cache-Control, CORS) | ✓ VERIFIED | See Step 2 |
|
||||||
|
| Enclosure URLs in appcast XML are absolute `freno.me` URLs | ✓ VERIFIED | See Step 5 |
|
||||||
|
| Sparkle EdDSA signatures are valid (host-independent) | ✓ VERIFIED | See Step 6 |
|
||||||
|
| Dev build pointed at subdomain SUFeedURL successfully checks for + downloads update | PENDING | Requires DNS/Vercel config |
|
||||||
|
| Existing build on legacy SUFeedURL is unaffected (no regression) | ✓ VERIFIED | freno.me endpoints work |
|
||||||
|
| SUFeedURL change for new builds is documented / filed against Gaze and InputHalo repos | ✓ VERIFIED | See docs/sparkle-sufeedurl-migration.md |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Complete DNS/Vercel configuration:** Add subdomains to Vercel and configure CNAMEs
|
||||||
|
2. **Run verification script:** `./scripts/verify-sparkle-dual-host.sh` after subdomains are configured
|
||||||
|
3. **Update SUFeedURL in native repos:** See `docs/sparkle-sufeedurl-migration.md`
|
||||||
|
4. **Test in dev builds:** Verify Sparkle detects + downloads updates with subdomain URLs
|
||||||
|
5. **Test regression:** Verify old builds on legacy URLs still work
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `vercel.json` | Host-based rewrites (pass-through rules in place) |
|
||||||
|
| `src/routes/api/Gaze/appcast.xml.ts` | Gaze appcast route (serves from S3) |
|
||||||
|
| `src/routes/api/InputHalo/appcast.xml.ts` | InputHalo appcast route (serves from S3) |
|
||||||
|
| `src/routes/api/downloads/[filename].ts` | DMG download route (serves from S3) |
|
||||||
|
| `scripts/verify-sparkle-dual-host.sh` | Verification script |
|
||||||
|
| `docs/sparkle-dual-host-support.md` | Complete documentation |
|
||||||
|
| `docs/sparkle-sufeedurl-migration.md` | SUFeedURL migration guide |
|
||||||
58
docs/sparkle-sufeedurl-migration.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# SUFeedURL Migration for New Native Builds
|
||||||
|
|
||||||
|
This document tracks the `SUFeedURL` change needed in the Gaze and InputHalo native Swift repos to use subdomain-based feed URLs.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [ ] Gaze: Update `SUFeedURL` in Info.plist (~/Code/Gaze/)
|
||||||
|
- [ ] InputHalo: Update `SUFeedURL` in Info.plist (~/Code/InputHalo/)
|
||||||
|
|
||||||
|
## SUFeedURL Changes
|
||||||
|
|
||||||
|
### Gaze
|
||||||
|
|
||||||
|
**File:** `~/Code/Gaze/` — Info.plist
|
||||||
|
|
||||||
|
**Current (Legacy):**
|
||||||
|
```
|
||||||
|
https://freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**New (Subdomain):**
|
||||||
|
```
|
||||||
|
https://gaze.freno.me/api/Gaze/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
### InputHalo
|
||||||
|
|
||||||
|
**File:** `~/Code/InputHalo/` — Info.plist
|
||||||
|
|
||||||
|
**Current (Legacy):**
|
||||||
|
```
|
||||||
|
https://freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**New (Subdomain):**
|
||||||
|
```
|
||||||
|
https://inputhalo.freno.me/api/InputHalo/appcast.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
1. **Keep legacy URL working:** Old builds continue to work via the `/api/*` pass-through rewrite on Vercel
|
||||||
|
2. **No appcast regeneration needed:** The same S3 appcast files serve both URLs
|
||||||
|
3. **EdDSA signatures are host-independent:** No signature changes needed
|
||||||
|
4. **Test before release:** Verify Sparkle detects updates with the new subdomain URL in a dev build
|
||||||
|
|
||||||
|
## Verification Steps
|
||||||
|
|
||||||
|
1. Set `SUFeedURL` in Info.plist to new subdomain URL
|
||||||
|
2. Build the app
|
||||||
|
3. In the app: "Check for Updates..." → Should find the latest version
|
||||||
|
4. Verify the download completes successfully
|
||||||
|
5. Verify the EdDSA signature verification passes
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Dual-Host Support Documentation](./sparkle-dual-host-support.md)
|
||||||
|
- [Verification Script](../scripts/verify-sparkle-dual-host.sh)
|
||||||
89
docs/subdomain-setup.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Subdomain Setup — freno.me
|
||||||
|
|
||||||
|
This document records the DNS and Vercel domain configuration for the four product subdomains.
|
||||||
|
|
||||||
|
## DNS Configuration
|
||||||
|
|
||||||
|
**DNS Provider:** Google Domains (nameservers: `ns-cloud-a1` through `ns-cloud-a4.googledomains.com`)
|
||||||
|
|
||||||
|
Add the following CNAME records in the [Google Domains DNS console](https://domains.google.com/registrar/freno.me/dns):
|
||||||
|
|
||||||
|
| Subdomain | Type | Target | TTL |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nessa` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||||
|
| `lineage` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||||
|
| `gaze` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||||
|
| `inputhalo` | CNAME | `cname.vercel-dns.com` | Automatic |
|
||||||
|
|
||||||
|
**After adding records:** Wait for DNS propagation (typically minutes) and for Vercel to auto-issue SSL certificates for each subdomain.
|
||||||
|
|
||||||
|
## Vercel Project Domains
|
||||||
|
|
||||||
|
Add the following domains in the [Vercel project Settings → Domains](https://vercel.com/your-team/freno-dev/settings/domains):
|
||||||
|
|
||||||
|
| Domain | Redirects to |
|
||||||
|
|---|---|
|
||||||
|
| `nessa.freno.me` | (no redirect — serves `src/routes/nessa/*` via vercel.json rewrite) |
|
||||||
|
| `lineage.freno.me` | (no redirect — serves `src/routes/lineage/*` via vercel.json rewrite) |
|
||||||
|
| `gaze.freno.me` | (no redirect — serves `src/routes/gaze/*` via vercel.json rewrite) |
|
||||||
|
| `inputhalo.freno.me` | (no redirect — serves `src/routes/inputhalo/*` via vercel.json rewrite) |
|
||||||
|
|
||||||
|
**Do NOT set any of these as the Production Branch domain** — `freno.me` remains the production domain.
|
||||||
|
|
||||||
|
## Rewrite Architecture (`vercel.json`)
|
||||||
|
|
||||||
|
The rewrites are defined in `vercel.json` with **two groups, ordered precisely**:
|
||||||
|
|
||||||
|
### Group 1: `/api/*` pass-throughs (must come first)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/api/$1" }
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/api/$1" }
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/api/$1" }
|
||||||
|
{ "source": "/api/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/api/$1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
These pass API requests on subdomains straight through to the existing `/api/*` routes. This enables **dual-host Sparkle appcast support**: `gaze.freno.me/api/Gaze/appcast.xml` hits the same `src/routes/api/Gaze/appcast.xml.ts` route as `freno.me/api/Gaze/appcast.xml`.
|
||||||
|
|
||||||
|
### Group 2: `/(.*)` catch-all rewrites
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/nessa/$1" }
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/lineage/$1" }
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" }
|
||||||
|
{ "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
These rewrite non-API requests on each subdomain to its internal route prefix. Vercel matches top-to-bottom, so the `/api/*` rules above catch API paths first.
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
After DNS propagation and Vercel certificate issuance:
|
||||||
|
|
||||||
|
- [ ] `dig nessa.freno.me` returns the Vercel CNAME
|
||||||
|
- [ ] `dig lineage.freno.me` returns the Vercel CNAME
|
||||||
|
- [ ] `dig gaze.freno.me` returns the Vercel CNAME
|
||||||
|
- [ ] `dig inputhalo.freno.me` returns the Vercel CNAME
|
||||||
|
- [ ] `curl -sI https://nessa.freno.me/ | head -1` → `HTTP/2 200`
|
||||||
|
- [ ] `curl -sI https://lineage.freno.me/ | head -1` → `HTTP/2 200`
|
||||||
|
- [ ] `curl -sI https://gaze.freno.me/ | head -1` → `HTTP/2 200`
|
||||||
|
- [ ] `curl -sI https://inputhalo.freno.me/ | head -1` → `HTTP/2 200`
|
||||||
|
- [ ] `curl -sI https://freno.me/api/Gaze/appcast.xml | head -1` → `HTTP/2 200` (regression)
|
||||||
|
- [ ] `curl -sI https://freno.me/api/InputHalo/appcast.xml | head -1` → `HTTP/2 200` (regression)
|
||||||
|
- [ ] `curl -sI https://freno.me/ | head -1` → `HTTP/2 200` (regression)
|
||||||
|
- [ ] All four subdomains have valid SSL certificates (no browser warnings)
|
||||||
|
|
||||||
|
## Auth Boundaries
|
||||||
|
|
||||||
|
Auth remains **host-scoped** — no cookie domain broadening:
|
||||||
|
|
||||||
|
- `freno.me` web JWT cookies: host-only on `freno.me`
|
||||||
|
- Nessa: Clerk session tokens (independent)
|
||||||
|
- Lineage: mobile JWT (independent)
|
||||||
|
- Gaze/InputHalo: no web auth
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- DNS records must be added at **Google Domains** (not Vercel's DNS) since freno.me uses Google's nameservers.
|
||||||
|
- Subdomains will 404 until route files exist in `src/routes/<prefix>/*` (content tasks 05–11).
|
||||||
|
- The `bun run build` gate is worktree-friendly; run it before deploying.
|
||||||
24
marketing/README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# 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.
|
||||||
1
marketing/approved/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# approved
|
||||||
1
marketing/briefs/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# briefs
|
||||||
4
marketing/calendar.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Scheduled pushes: date, channel, status. Statuses: draft | pending | approved | posted. Move nothing to posted without the artifact living in approved/ first — see positioning.md rule 5.",
|
||||||
|
"pushes": []
|
||||||
|
}
|
||||||
1
marketing/drafts/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# drafts
|
||||||
478
marketing/drafts/faq-from-objections.md
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
# `/faq` from the objections we already have
|
||||||
|
|
||||||
|
**Date:** 2026-10-29 · **Owner:** CMO · **Status:** CTO-verified (FRE-329) and CoS-decided (FRE-334, option A: withdraw both) on 2026-10-29; **awaiting the sponsor's publish approval** — not approved, not published · **Source issue:** FRE-326
|
||||||
|
**Page:** `nook.freno.me/faq` · **Page source:** `freno-dev/src/routes/nook/faq.tsx` · **Site commit read:** `c1c498e` (branch `faq/from-objections-2026-10-29`, unmerged)
|
||||||
|
**Product commit read:** The-Nook `f9cbd879` — the exact revision the CTO verified against (0.13.4, build 37)
|
||||||
|
|
||||||
|
Every entry below is traceable to a real question or a real report. Nothing is invented to fill a
|
||||||
|
category. No testimonial is used. No price, refund term, or umbrella/cross-promotion answer appears
|
||||||
|
here — those stay with FRE-204, the Chief of Staff and the sponsor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What I could read, and what I could not
|
||||||
|
|
||||||
|
| Source | What it is | Read on | What it yielded |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nook.freno.me/faq` | The live page | 2026-10-29 | Six questions already published: privacy, notch, battery, permissions, macOS 14.2, offline. |
|
||||||
|
| FRE-326 description | This board issue | 2026-10-29 | Two purchase-blocking questions named verbatim: *"does it work with Cursor?"* and *"what happens when my agent is idle?"* |
|
||||||
|
| NOOK issue corpus | 948 issues imported from Plane, plus the 324-issue Paperclip board | 2026-10-08 → 2026-10-28 | User-observable defect reports. These are reports, not captured asks; they prove an objection is live. |
|
||||||
|
| `freno-dev/marketing/positioning.md` | Voice, approved claims, competitor ban | 2026-10-29 | The register and the "never name a competitor" rule. |
|
||||||
|
| The-Nook code | `AgentRegistry.swift`, `SessionModels.swift`, `Terminal*`, `hook/setup.go`, `PluginInstaller.swift`, `LicenseService.swift`, `main.swift` | 2026-10-29 | The technical facts each answer is allowed to use. |
|
||||||
|
| NOOK-121 | The original `/faq` creation (2026-10-08) | 2026-10-29 | Confirms the six live questions came from the strategy's named objections, not from a support corpus. Refund was deliberately dropped by Mike. |
|
||||||
|
|
||||||
|
### The gap that shapes this draft
|
||||||
|
|
||||||
|
**Support mail is not readable from this run.** There is no mailbox on the machine; the in-app
|
||||||
|
"Report a bug" form `POST`s to `freno-dev/src/routes/api/the-nook/report.ts`, which emails the
|
||||||
|
developer and stores nothing queryable. `/privacy` and the repo contain no support corpus.
|
||||||
|
|
||||||
|
**Reply threads are not collected.** `freno-dev/marketing/listening.md` is an empty table; FRE-201
|
||||||
|
(the listening job) has not produced a thread yet.
|
||||||
|
|
||||||
|
**Directory comments do not exist yet.** FRE-207 has a submission packet, not live listings; none of
|
||||||
|
the directories has a comment thread about The Nook to mine.
|
||||||
|
|
||||||
|
**Consequence:** I cannot rank by observed ask frequency, because no ask corpus exists. What follows
|
||||||
|
ranks by **purchase-blocking weight first**, then by **the number of named reports in our own issue
|
||||||
|
corpus that belong to the cluster**. The report counts are a proxy for "this objection is live", not
|
||||||
|
a count of people who asked. I have not put a percentage on anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The register — clusters, sources, frequency, and whether they block a purchase
|
||||||
|
|
||||||
|
| # | Cluster | The question in the buyer's words | Verbatim source(s), with dates | Frequency evidence | Blocks a purchase? | Disposition |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 1 | Privacy / what leaves the Mac | "Does The Nook read my prompts or my code?" | Live `/faq`, 2026-10-29; code findings in `main.swift:272-278`, `LicenseService.swift:254`, `docs/sidecar-frames.md` | 1 live question + 3 named code findings | **Yes — the whole decision for this audience** | **Withdraw the live answer; replacement held.** See §4. |
|
||||||
|
| 2 | Agent fit / Cursor | "Does it work with Cursor — and can I approve from the notch, or only watch?" | FRE-326, 2026-10-29: *"does it work with Cursor?"*; NOOK-643 (2026-10-20), NOOK-695 (2026-10-20), NOOK-699 (2026-10-20), NOOK-707 (2026-10-21), NOOK-638 (2026-10-20) | 1 board question + 5 reports | **Yes** | **Propose E1** |
|
||||||
|
| 3 | Idle / is it actually working? | "What happens when my agent is idle?" | FRE-326, 2026-10-29: *"what happens when my agent is idle?"*; NOOK-281 (2026-10-12), NOOK-415 (2026-10-15), NOOK-417 (2026-10-15), NOOK-823 (2026-10-22), NOOK-898 (2026-10-23) | 1 board question + 5 reports | **Yes** | **Propose E2** |
|
||||||
|
| 4 | Install trust / permissions | "What permissions does it need — and what does it write into my agent's config?" | Live `/faq`, 2026-10-29; NOOK-733 (2026-10-21), NOOK-648 (2026-10-20), NOOK-685 (2026-10-20), NOOK-418 (2026-10-15), NOOK-568 (2026-10-17) | 1 live question + 5 reports | **Yes** | **Propose E3** |
|
||||||
|
| 5 | Terminal / tmux fit | "Does it work with my terminal, including tmux?" | NOOK-302 (2026-10-13), NOOK-310 (2026-10-13), NOOK-788 (2026-10-22) | 3 reports; no captured ask | **Yes for tmux-first buyers** | **Propose E4** — report-sourced, not an ask |
|
||||||
|
| 6 | Notch / display | “Do I need a Mac with a notch?” | Live `/faq`, 2026-10-29; NOOK-290 (2026-10-12), NOOK-291 (2026-10-12), NOOK-386 (2026-10-14) | 1 live question + 3 reports | Compatibility; rarely the blocker | **Rewrite E5** — the pointer-display sentence was never true |
|
||||||
|
| 7 | Battery / cost | "How much battery does it cost?" | Live `/faq`, 2026-10-29; NOOK-850 (2026-10-23), NOOK-851 (2026-10-23), NOOK-877 (2026-10-23) | 1 live question + 3 reports | Curious, not a hard blocker | **Rewrite E6 without a number** |
|
||||||
|
| 8 | macOS floor | "Why macOS 14.2 or later?" | Live `/faq`, 2026-10-29; NOOK-881 (2026-10-23) | 1 live question + 1 report | Compatibility; a hard no on 14.0/14.1 | **Keep current answer** |
|
||||||
|
| 9 | Offline | "Does it work offline?" | Live `/faq`, 2026-10-29; NOOK-494 (2026-10-16) | 1 live question + 1 report | Compatibility; a hard no for offline-only | **Rewrite E8 for the phone exception** |
|
||||||
|
| 10 | Trial / licence mechanics | "What am I actually buying — trial, Macs, updates?" | NOOK-682 (2026-10-20), NOOK-684 (2026-10-20), NOOK-60 (2026-10-08) | 2 reports; no captured ask | **Yes** | **Held** — no verbatim ask; do not invent one |
|
||||||
|
| 11 | Remote agents / containers | "Can I approve an agent running on a remote box or in Docker?" | FRE-323, FRE-320, FRE-315; `marketing/positioning.md` claims remote SSH | Many reports; the claim is unverified | **Yes — the launch claim** | **Held** — no answer before the demo |
|
||||||
|
| 12 | Phone / Watch | "Is there an iPhone app? Do I need one?" | NOOK-939 (2026-10-27), NOOK-294 (backlog) | 2 reports; status unverified | Curious; would be blocking if sold | **Held** — CTO status answer first |
|
||||||
|
| 13 | Refund / price | "What's your refund policy? Is $10 the real price?" | FRE-204, NOOK-121 | No readable source; contractual | **Yes** | **Out of scope** — FRE-204, CoS + sponsor |
|
||||||
|
|
||||||
|
### What changed since the last register (FRE-204)
|
||||||
|
|
||||||
|
- The live page already has six entries; this draft **keeps one** (macOS 14.2), **rewrites four**
|
||||||
|
(permissions, battery, offline, and notch — whose pointer-display sentence the CTO found was never
|
||||||
|
true), **withdraws one** (privacy), and **proposes three new ones** (E1, E2, E4). It does not re-ask
|
||||||
|
the six questions as if they were new.
|
||||||
|
- The privacy answer is not merely unverified — it is **false at the current build**. It must come
|
||||||
|
down in the same edit, not wait for a nicer replacement.
|
||||||
|
- Cursor is now a supported agent row (`AgentRegistry.swift:524`), so "does it work with Cursor?"
|
||||||
|
has a factual answer today. It was an open question when the live page was written.
|
||||||
|
|
||||||
|
### CTO verdict (FRE-329, 2026-10-29)
|
||||||
|
|
||||||
|
The CTO read The-Nook `f9cbd879`, the site at `c1c498e` and the live page, and checked every entry
|
||||||
|
against the code. **No entry is cut.** Six change text, exactly as below; E1 and E7 ship as written.
|
||||||
|
|
||||||
|
| Entry | Verdict | What changed in this revision |
|
||||||
|
|---|---|---|
|
||||||
|
| E1 Agent fit / Cursor | **verified** | nothing |
|
||||||
|
| E2 Idle | verified, one sentence tightened | the row-eviction sentence |
|
||||||
|
| E3 Permissions / config | **not verified** | three sentences: Login Items toggle, per-event entries, `~/.nook` |
|
||||||
|
| E4 Terminal / tmux | **not verified** | one sentence: which terminals take a typed reply |
|
||||||
|
| E5 Notch | **not verified** | one sentence: one island per enabled display, not the pointer's display |
|
||||||
|
| E6 Battery | verified, one clause tightened | "never runs them in the background" |
|
||||||
|
| E7 macOS 14.2 | **verified** | nothing |
|
||||||
|
| E8 Offline | **not verified** | one sentence, plus a six-row network list in its gate |
|
||||||
|
| §4 privacy | all three findings confirmed; withdrawal is right | `positioning.md` rule 3 comes down in the same change |
|
||||||
|
|
||||||
|
The verdict adds two things to the plan, both now in §4 and §7: the false claim's **source rule** in
|
||||||
|
`marketing/positioning.md` — rule 3 and its approved-claims row — must be struck in the same change
|
||||||
|
as the withdrawal, or the next page restates it; and the egress inventory's scope widens to cover
|
||||||
|
weather, the network panel, the speed test, the changelog, the bug report and Sparkle, plus Sentry's
|
||||||
|
`sendDefaultPii` and trace sample rate.
|
||||||
|
|
||||||
|
The verdict's full text and its per-sentence evidence is
|
||||||
|
[FRE-329's `faq-verdict` document](/FRE/issues/FRE-329#document-faq-verdict). Nothing in it is
|
||||||
|
reproduced here as a new claim; the entry text below is the verdict's own replacement wording, except
|
||||||
|
E4, where I took the verdict's first option (keep the Alacritty clause) and narrowed the jump
|
||||||
|
sentence, which the verdict itself notes is `open -b` for three of the seven names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Proposed entries — page-ready
|
||||||
|
|
||||||
|
Each entry carries the six required things: **claim, audience, channel, owner, date, and what has to
|
||||||
|
be true before it ships.** The answer text is the copy to paste; the trace is the evidence a CTO can
|
||||||
|
check.
|
||||||
|
|
||||||
|
### E1 · "Does it work with my agent — and can I approve from the notch, or only watch?"
|
||||||
|
|
||||||
|
> **It depends on the agent, and Cursor is the useful example.** Cursor is watched, not gated: its
|
||||||
|
> sessions, finished turns and errors appear in the island, but when Cursor asks for permission the
|
||||||
|
> prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs
|
||||||
|
> and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the
|
||||||
|
> decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. One limit:
|
||||||
|
> `cursor-agent -p` one-shot runs send no turn end, so they appear but do not get a completion card.
|
||||||
|
|
||||||
|
- **Question source:** FRE-326, 2026-10-29 (*"does it work with Cursor?"*); NOOK-643, 2026-10-20
|
||||||
|
(*"No permission hook is installed, deliberately: Cursor blocks the action when a permission hook
|
||||||
|
prints nothing…"*).
|
||||||
|
- **Claim:** agent gating is per-agent; Cursor is watch-only.
|
||||||
|
- **Audience:** the buyer whose agent is not Claude Code.
|
||||||
|
- **Channel:** `/faq`. No FAQ structured data in this pass — see §6.
|
||||||
|
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||||
|
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||||
|
- **Must be true:** at the shipped commit, Cursor still reads `.missing(.approvals)` with the same
|
||||||
|
reason, and `.available(.sessions)`, `.available(.completion)` and `.available(.errors)` as stated
|
||||||
|
(`AgentRegistry.swift:523-555`, which is where the CTO read it). The separate `/agents` page is
|
||||||
|
incomplete for Cursor and the other newer rows; that is a page correction, not a gate for this
|
||||||
|
self-contained answer.
|
||||||
|
|
||||||
|
### E2 · "What happens when my agent is idle?"
|
||||||
|
|
||||||
|
> **Idle means the last turn is over, or the session has not produced anything yet — not that the
|
||||||
|
> process is gone.** A running process is liveness, not work: a just-opened agent window can show
|
||||||
|
> idle until you type. When an agent finishes a turn with a final message, the row stays green for
|
||||||
|
> ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot
|
||||||
|
> report for that session, and the row says so instead of pretending it is idle. Nothing is lost when
|
||||||
|
> a row goes idle — the next prompt brings it back and flips it to running. The row itself leaves the
|
||||||
|
> island 15 minutes after the turn (5 if the turn ended without a reply), so a long-quiet session
|
||||||
|
> reappears on its next event instead of sitting there. If a session that is obviously working says
|
||||||
|
> idle, that is a bug; tell us.
|
||||||
|
|
||||||
|
- **Question source:** FRE-326, 2026-10-29 (*"what happens when my agent is idle?"*); NOOK-898,
|
||||||
|
2026-10-23 (*"kimi payloads fail to decode, so every Kimi session stays idle"*); NOOK-823,
|
||||||
|
2026-10-22 (*"Codex terminal: sessions become idle or disappear prematurely"*); NOOK-417,
|
||||||
|
2026-10-15 (*"Never render plain Idle for a session whose integration is dark"*).
|
||||||
|
- **Claim:** idle is a statement about the last turn, not a guess at the process; stale is a separate
|
||||||
|
state.
|
||||||
|
- **Audience:** anyone watching a fleet and wondering whether to intervene.
|
||||||
|
- **Channel:** `/faq`.
|
||||||
|
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||||
|
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||||
|
- **Must be true:** `SessionModels.swift:511,552,564-577` still defines `islandStatus` this way (no
|
||||||
|
content → idle; complete → ready for 10 minutes → idle; `integrationStale` → stale) and
|
||||||
|
`SessionState.swift:35,40` still evicts a finished turn after 15 minutes, 5 when it ended without a
|
||||||
|
reply. If an open bug makes a working agent render idle at the shipped commit, the "that is a bug"
|
||||||
|
sentence stays but the bug gets a named issue.
|
||||||
|
|
||||||
|
### E3 · "What permissions does it need — and what does it write into my agent's config?"
|
||||||
|
|
||||||
|
> **The app itself runs unprivileged.** Fan control and charge limiting go through a small helper
|
||||||
|
> that macOS requires to run as root for hardware access; the app registers it on first use and takes
|
||||||
|
> you to System Settings › General › Login Items & Extensions, where you switch it on once. For each
|
||||||
|
> agent it adds what that agent needs — a hook group per event it listens to, or one plugin link —
|
||||||
|
> inside the agent's own config file or directory. It keeps its own files under `~/.nook` — the hook
|
||||||
|
> binary and one plugin copy per agent — and adds only its own entries to that agent's config. A hook
|
||||||
|
> entry you added yourself in the same file survives install and uninstall, and it only touches the
|
||||||
|
> records and switches it created. Uninstall removes exactly what it wrote — and switches off what it
|
||||||
|
> turned on — with no leftover links or orphaned files.
|
||||||
|
|
||||||
|
- **Question source:** live `/faq`, 2026-10-29 (*"What permissions does it need?"*); NOOK-418,
|
||||||
|
2026-10-15 (a custom agent's install target is never shown to the user); NOOK-733, 2026-10-21 (a
|
||||||
|
Codex hook installed but not trusted, so it never ran); NOOK-648, 2026-10-20 and NOOK-685,
|
||||||
|
2026-10-20 (uninstall leftovers); NOOK-568, 2026-10-17 (helper re-registration tore down an
|
||||||
|
approved helper).
|
||||||
|
- **Claim:** reversible install; the helper is the only root step; uninstall is exact.
|
||||||
|
- **Audience:** the buyer who has a hand-tuned `~/.claude/settings.json` or `~/.codex/hooks.json`.
|
||||||
|
- **Channel:** `/faq`.
|
||||||
|
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||||
|
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||||
|
- **Must be true:** `hook/setup.go`'s strip functions and `PluginInstaller.uninstall` still remove
|
||||||
|
only their own records at the shipped commit (`removeIfOurs`, `stripOurGroups`/`groupIsOurs`); the
|
||||||
|
helper is still registered through `SMAppService.daemon` with a Login Items toggle and no password
|
||||||
|
prompt (`PrivilegedHelper.swift:62-112,204-208`); the custom-agent target is still shown and
|
||||||
|
accepted before install (NOOK-418); the only host switch uninstall flips is Amp's own hook-enable
|
||||||
|
flag (`PluginInstaller.swift:379-385`) — and the answer now says so.
|
||||||
|
|
||||||
|
### E4 · "Does it work with my terminal, including tmux?"
|
||||||
|
|
||||||
|
> **Yes — it recognises Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux. Four of those
|
||||||
|
> take a typed reply; the rest are watched.** A session is tied to the terminal that started it. Jump
|
||||||
|
> lands in the right window for Terminal.app, iTerm2, kitty and any tmux pane — in tmux, the right
|
||||||
|
> pane; on Warp, WezTerm and Ghostty it brings the app forward instead. Replies type straight back
|
||||||
|
> into Terminal.app, iTerm2, kitty and any tmux pane; on Warp, WezTerm and Ghostty you answer in the
|
||||||
|
> app's own window, and Alacritty takes no typed reply from another app at all.
|
||||||
|
|
||||||
|
- **Question source:** no captured ask. Underlying reports: NOOK-302, 2026-10-13 (*"a tmux session
|
||||||
|
shows only 'tmux', and its outer terminal comes from whoever started the tmux server"*); NOOK-310,
|
||||||
|
2026-10-13 (*"jump and send address tmux panes on the default server, ignoring the session's tmux
|
||||||
|
socket"*).
|
||||||
|
- **Claim:** seven terminal families and tmux panes included. Remote panes are not claimed here;
|
||||||
|
the remote launch story is still held.
|
||||||
|
- **Audience:** anyone whose workflow is tmux-first.
|
||||||
|
- **Channel:** `/faq`.
|
||||||
|
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||||
|
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||||
|
- **Must be true:** `TerminalAncestry.programs` still recognises those seven names; `TerminalSend.attempts`
|
||||||
|
still builds a delivery channel only for tmux, herdr, kitty, iTerm2 and Terminal.app and refuses
|
||||||
|
Alacritty by name; `TerminalJump` still raises the app rather than the window for Warp, WezTerm and
|
||||||
|
Ghostty. This entry is **report-sourced, not ask-sourced**; if a captured ask never arrives, it is
|
||||||
|
still publishable because the reports are real, but it ranks below E1–E3 until one does.
|
||||||
|
|
||||||
|
### E5 · "Do I need a Mac with a notch?"
|
||||||
|
|
||||||
|
> **No. On a notched MacBook the island sits in the notch. On machines and displays without one, it
|
||||||
|
> sits at the top centre of the display — the same pill with the same slots.** You get one island on
|
||||||
|
> every display it is turned on for (all connected displays on first launch), each anchored to the top
|
||||||
|
> of its own screen; Settings › Displays trims that list, and the pointer only decides which display's
|
||||||
|
> floating controls are live. Everything inside the island — agent cards, fan control, the stats strip
|
||||||
|
> — works identically either way.
|
||||||
|
|
||||||
|
- **Question source:** live `/faq`, 2026-10-29; NOOK-290, 2026-10-12 and NOOK-291, 2026-10-12
|
||||||
|
(non-notch display geometry bugs), NOOK-386, 2026-10-14 (empty pill tucks away on a non-notch
|
||||||
|
display).
|
||||||
|
- **Claim:** a notch is optional; external displays work.
|
||||||
|
- **Audience:** Mac mini / Studio / external-monitor buyers.
|
||||||
|
- **Channel:** `/faq`.
|
||||||
|
- **Owner:** CMO keeps the question and rewrites the answer; CTO verified 2026-10-29.
|
||||||
|
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||||
|
- **Must be true:** `IslandDisplayManager` still owns one island per enabled display, `DisplayStore`
|
||||||
|
still snapshots every connected display on first launch (`DisplayStore.swift:52-59`), and the
|
||||||
|
non-notch geometry tests still pass. Nothing tests that first-launch snapshot today — that is a test
|
||||||
|
gap to file, not a gate on this answer.
|
||||||
|
|
||||||
|
### E6 · "How much battery does it cost?"
|
||||||
|
|
||||||
|
> **It is a native app, not a background agent.** It watches the agents you already run and never
|
||||||
|
> runs them in the background, so their draw is the number that matters. We do not have a published battery
|
||||||
|
> measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs
|
||||||
|
> more than it should on your Mac, that is a bug — tell us.
|
||||||
|
|
||||||
|
- **Question source:** live `/faq`, 2026-10-29; NOOK-850, 2026-10-23 (*"the idle app does work
|
||||||
|
nothing asked for (CPU wins from a live profile)"*), NOOK-851, 2026-10-23 (a 30 fps timeline ticks
|
||||||
|
the fan icon at idle), NOOK-877, 2026-10-23 (compact Nook still costs too much CPU at rest).
|
||||||
|
- **Claim:** native app, not a background agent; **no numeric battery claim**.
|
||||||
|
- **Audience:** the buyer checking whether this is another menu-bar battery sink.
|
||||||
|
- **Channel:** `/faq`.
|
||||||
|
- **Owner:** CMO writes; CTO verified the no-number wording 2026-10-29.
|
||||||
|
- **Date:** CTO verified the no-number wording 2026-10-29; publish on approval, target 2026-11-05.
|
||||||
|
- **Must be true:** either a measurement exists and is quoted, or the no-number sentence ships. Do
|
||||||
|
not restore "the heaviest thing it does is a sensor read" — the profile findings above contradict
|
||||||
|
it.
|
||||||
|
|
||||||
|
### E7 · "Why macOS 14.2 or later?"
|
||||||
|
|
||||||
|
> **The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS
|
||||||
|
> 14.2 (Sonoma).** The app links against it, so 14.0 and 14.1 cannot launch it.
|
||||||
|
|
||||||
|
- **Question source:** live `/faq`, 2026-10-29; NOOK-881, 2026-10-23 (*"A macOS 14.0/14.1 Mac cannot
|
||||||
|
launch the app at all: dyld dies on a strongly linked CATapDescription overlay symbol"*).
|
||||||
|
- **Claim:** the floor is 14.2 because of the audio tap API, not a policy choice.
|
||||||
|
- **Audience:** anyone on an older macOS.
|
||||||
|
- **Channel:** `/faq`.
|
||||||
|
- **Owner:** CMO keeps; CTO verified 2026-10-29.
|
||||||
|
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||||
|
- **Must be true:** `Package.swift:12` and `LSMinimumSystemVersion` still say 14.2, and
|
||||||
|
`DeploymentFloorTests` still refuses an older floor.
|
||||||
|
|
||||||
|
### E8 · "Does it work offline?"
|
||||||
|
|
||||||
|
> **The Mac app does.** The island, agent cards, permission cards and fan control work with no
|
||||||
|
> network; the hooks talk to the app over a local socket. The network is used for update checks,
|
||||||
|
> licence activation, crash reports and the optional trial email, plus a few things only when you use
|
||||||
|
> them: weather (asked for through our relay, with the location you chose), the network panel's
|
||||||
|
> IP-and-ISP lookup, the speed test, the changelog in Settings, and a bug report if you send one. The
|
||||||
|
> phone link is the exception: when you are away from your Mac, the phone reaches it through our
|
||||||
|
> relay, so that part needs a connection.
|
||||||
|
|
||||||
|
- **Question source:** live `/faq`, 2026-10-29; NOOK-494, 2026-10-16 (*"iOS: the main list gives no
|
||||||
|
sign the phone is offline from the Mac"*).
|
||||||
|
- **Claim:** the Mac app is offline-first; the phone link is not.
|
||||||
|
- **Audience:** the buyer with a disconnected or privacy-sensitive setup.
|
||||||
|
- **Channel:** `/faq`.
|
||||||
|
- **Owner:** CMO writes; CTO verified 2026-10-29.
|
||||||
|
- **Date:** CTO verified 2026-10-29 ([FRE-329](/FRE/issues/FRE-329)); publish on approval, target 2026-11-05.
|
||||||
|
- **Must be true:** the only network uses at the shipped commit are the update check, licence
|
||||||
|
activation, crash reporting, the optional trial email and the phone relay, plus exactly these five
|
||||||
|
user-triggered ones — `/v1/weather` on the relay (`SidecarRelayAPI.swift:182-196`),
|
||||||
|
`NetworkInfoService.swift:60-88,276`, `SpeedTest.swift:36,57`, `ChangelogModel.swift:102`,
|
||||||
|
`BugReportClient.swift:72-94` — and `/privacy` lists the same set. This entry shares its gate with
|
||||||
|
the privacy correction below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The correction that is not a new entry: privacy
|
||||||
|
|
||||||
|
**Live answer, quoted from `nook.freno.me/faq` on 2026-10-29:**
|
||||||
|
|
||||||
|
> "No. The hooks and plugins that connect your agents to The Nook send session metadata only — which
|
||||||
|
> agent is running, which session, which event happened, which terminal it lives in. No prompt text
|
||||||
|
> and no code content leaves your device. Two things do leave the device, and we'd rather say so
|
||||||
|
> plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start.
|
||||||
|
> That's the whole list."
|
||||||
|
|
||||||
|
**Why it cannot stay up:** it is contradicted by the current build.
|
||||||
|
|
||||||
|
1. **Sentry starts unconditionally with `sendDefaultPii = true`** (`Sources/NookApp/main.swift:272-278`).
|
||||||
|
That is crash and error reporting with personal identifiers, not "anonymous usage telemetry".
|
||||||
|
2. **The licence/trial call sends `IOPlatformUUID` and the Mac's device name** (`LicenseService.swift:254`,
|
||||||
|
`:305`). Precision from the verdict: the identifiers ride on activation, not on every launch — a
|
||||||
|
licensed Mac's launch call is `/status` with the key only (`:290-295`).
|
||||||
|
3. **With the phone link on, prompt and reply text transit `nook-relay.freno.me`, sealed end to end**
|
||||||
|
(`docs/sidecar-frames.md`). The relay can route the payload but cannot read it; the live answer
|
||||||
|
still says no prompt text leaves the device.
|
||||||
|
|
||||||
|
`/privacy` discloses the hardware UUID, device name and Stripe, but **not** Sentry and **not** the
|
||||||
|
relay. So there is no replacement sentence I can write that is both true and consistent with the
|
||||||
|
privacy page today.
|
||||||
|
|
||||||
|
**Proposed:** remove the privacy entry from `/faq` in the same edit that ships the other entries,
|
||||||
|
rather than replace it with another sentence I cannot stand behind. The replacement is held until
|
||||||
|
the Security Engineer's egress inventory and the `/privacy` update land.
|
||||||
|
|
||||||
|
**Decided 2026-10-29 — option A, withdraw both.** The Chief of Staff ruled on
|
||||||
|
[FRE-334](/FRE/issues/FRE-334) (`decision` document) that the `/faq` answer and `positioning.md`
|
||||||
|
rule 3 with its approved-claims row come down **in one commit**, and that no privacy claim is written
|
||||||
|
anywhere until FRE-333 lands and the CTO verifies it. Three consequences for the sequence below: the
|
||||||
|
withdrawal is no longer one of the sponsor's options, it is settled company policy; the publish card
|
||||||
|
asks one thing, publish the eight entries with the privacy answer withdrawn; and if an answer ever
|
||||||
|
ships the seven entries with the false answer still up, that is not a variant to absorb — it stops
|
||||||
|
and goes back to the CoS, because it would publish something the CoS has ruled out.
|
||||||
|
|
||||||
|
**The rule that produced the falsehood comes down with it.** The CTO's verdict traces the live answer
|
||||||
|
to `freno-dev/marketing/positioning.md` rule 3 (“Honest privacy scoping”) and to the matching
|
||||||
|
approved-claims row, which state the defensible claim as “No prompt or code content leaves the
|
||||||
|
device” with the backing “envelope carries session/event/terminal metadata only”. That backing is true
|
||||||
|
of the hook→app envelope and says nothing about what leaves the device, so if rule 3 stands while the
|
||||||
|
FAQ answer comes down, the next page restates the claim. Rule 3 and that row are struck in the same
|
||||||
|
commit as this patch (§7 step 3; the same edit also fixes the stale “macOS 14+” row in that table,
|
||||||
|
which NOOK-881 retired).
|
||||||
|
|
||||||
|
- **Claim:** none until the inventory exists — deliberately.
|
||||||
|
- **Audience:** the buyer for whom this is the whole decision.
|
||||||
|
- **Channel:** `/faq` (withdrawal), `/privacy` (replacement), `marketing/positioning.md` (the rule).
|
||||||
|
- **Owner:** CoS for the withdrawal decision — **done 2026-10-29, option A**; Security Engineer for the
|
||||||
|
egress inventory and the disclosure text; CTO for any build change and for verifying the replacement;
|
||||||
|
CMO for the replacement copy and the rule-3 strike.
|
||||||
|
- **Date:** withdrawal decision **done 2026-10-29** (was targeted 2026-10-31); inventory 2026-11-05;
|
||||||
|
replacement copy 2026-11-09, and it needs both the CTO's verification and the sponsor's approval —
|
||||||
|
rule-3 strike in the same commit as the page patch (§7 step 3).
|
||||||
|
- **Must be true:** every egress in the inventory is either in the answer or gone from the build, and
|
||||||
|
`/privacy` lists the same set. The inventory's scope is wider than the draft first drew it — it must
|
||||||
|
cover `main.swift`, `LicenseService.swift`, `Info.plist`, `ChangelogRelease.swift`,
|
||||||
|
`SidecarController.swift`, **plus** `SidecarRelayAPI`/`WeatherService` (`/v1/weather`),
|
||||||
|
`NetworkInfoService`, `SpeedTest`, `ChangelogModel`, `BugReportClient` and Sparkle's appcast — and
|
||||||
|
on the Sentry side not only that it starts but `sendDefaultPii` and the trace sample rate. Until
|
||||||
|
then, do not publish a privacy answer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Held — clusters with no entry yet
|
||||||
|
|
||||||
|
| Cluster | Why it is held | Owner and date |
|
||||||
|
|---|---|---|
|
||||||
|
| Trial / licence mechanics ("trial, Macs, updates") | No captured ask. NOOK-682 and NOOK-684 are defect reports, not a buyer question. The answer is factual (14 days, 3 Macs, Sparkle updates), but the task says do not invent a question to fill the slot. | CMO to capture an ask; CTO confirms `LicenseService.swift:43`, `:262` by 2026-11-03. |
|
||||||
|
| Remote agents / containers | The launch claim is unverified (FRE-315, FRE-322, FRE-323, FRE-320). No FAQ answer before the demo. | CTO evidence; CMO copy the day it exists. Revisit 2026-11-12. |
|
||||||
|
| Phone / Watch | Status is unverified; the landing page and FAQ currently disagree. | CTO status answer by 2026-11-03; CMO copy after. |
|
||||||
|
| Refund / price | Contractual, and explicitly out of this issue. | FRE-204, CoS + sponsor. |
|
||||||
|
| Custom agents | No captured ask; the `/agents` page already says a custom agent can wire itself in. | No entry until asked. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. What the page edit would change
|
||||||
|
|
||||||
|
**File:** `freno-dev/src/routes/nook/faq.tsx` (site `master` at `134f41e`; the branch is cut from
|
||||||
|
local `c1c498e`, which removed the remote claim from the landing page, not from the FAQ).
|
||||||
|
|
||||||
|
| Action | Entry |
|
||||||
|
|---|---|
|
||||||
|
| **Remove** | “Does The Nook read my prompts or my code?” — until the egress inventory and `/privacy` are true |
|
||||||
|
| **Rewrite (correction only)** | “Do I need a Mac with a notch?” → E5 (per-display island, not the pointer's display) |
|
||||||
|
| **Keep, with the exact text above** | “Why macOS 14.2 or later?” |
|
||||||
|
| **Rewrite** | “What permissions does it need?” → E3 (adds the config/uninstall answer) |
|
||||||
|
| **Rewrite** | “How much battery does it cost?” → E6 (no number) |
|
||||||
|
| **Rewrite** | “Does it work offline?” → E8 (phone exception, six-row network list) |
|
||||||
|
| **Add** | E1 (agent fit / Cursor) |
|
||||||
|
| **Add** | E2 (idle) |
|
||||||
|
| **Add** | E4 (terminal / tmux) |
|
||||||
|
| **Edit** | The file header comment that cites `positioning.md` rule 3 → “Privacy is deliberately absent until the egress inventory and /privacy make an honest answer possible.” |
|
||||||
|
| **Edit** | The `PageHead` description, which still advertises the privacy question → the new eight-entry list |
|
||||||
|
|
||||||
|
Order on the page: E1, E2, E3, E4, E5, E6, E7, E8. The mailto link, the accordion behavior and the
|
||||||
|
page title stay unchanged.
|
||||||
|
|
||||||
|
**Not in this pass:** FAQPage JSON-LD. The page has none today; adding structured data is an SEO
|
||||||
|
change, not an objection-mining change. It should be its own reviewed edit — and E1 no longer promises
|
||||||
|
it.
|
||||||
|
|
||||||
|
**Patch artifacts:** `freno-dev/marketing/drafts/faq-page.patch`, regenerated for this revision, and
|
||||||
|
`freno-dev/marketing/drafts/positioning-privacy-withdrawal.patch` for the rule-3 strike step 3 needs
|
||||||
|
to land with it (both proposals, neither applied). The page file and `positioning.md` are untouched
|
||||||
|
until the gates below are satisfied; `faq-page.patch` re-checked against `freno-dev` master
|
||||||
|
`c1c498e` on 2026-10-29 and applies cleanly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Publish gates and sequence
|
||||||
|
|
||||||
|
| Step | Owner | Date | What settles it |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1. CTO verifies the claims and evidence pointers in E1–E8 | CTO | **done 2026-10-29** | [FRE-329](/FRE/issues/FRE-329), verdict in its `faq-verdict` document: E1/E7 verified as written, E2/E6 verified after one sentence each, E3/E4/E5/E8 corrected above, **no entry cut**. Applied in this revision. |
|
||||||
|
| 2. Human approves this revision | Sponsor | target 2026-10-31 | The draft moves from `marketing/drafts/` to `marketing/approved/`, or the confirmation card on FRE-326 is accepted. The card asks one question now — publish the eight entries with the privacy answer withdrawn — because 2b is settled. |
|
||||||
|
| 2b. Withdrawal of the false privacy answer and of `positioning.md` rule 3 | Chief of Staff | **done 2026-10-29** | [FRE-334](/FRE/issues/FRE-334) `decision`: **option A, withdraw both, one commit**, no privacy claim anywhere until FRE-333 lands and the CTO verifies it. Recorded here so the page, the rule and the card cannot disagree. |
|
||||||
|
| 3. Apply the page patch, strike rule 3 + its approved-claims row, commit on `freno-dev` | CMO | 2026-11-03 | Both patches apply together to `freno-dev` master `c1c498e` (checked 2026-10-29, in a throwaway worktree, as one change set); the diff contains only the FAQ entries, the page comment/description, the `positioning.md` rule-3 strike and the 14.2 row. **The site's suite has not been run — this host has no `bun`** — so step 3 still has to run it before the commit. One commit, so the page and the rule move together. |
|
||||||
|
| 4. Merge to `freno-dev/master` (the publish) | Sponsor or Chief of Staff | 2026-11-05 | A human merge; Vercel deploys. No agent pushes to master. |
|
||||||
|
| 5. Verify the live page | CMO | 2026-11-05 | `https://nook.freno.me/faq` returns 200 and shows the eight entries with no privacy answer; the text matches the approved draft, sentence for sentence. |
|
||||||
|
|
||||||
|
**If the CTO had rejected an entry:** cut it from the patch and ship the rest — it did not reject any.
|
||||||
|
**If the sponsor does not approve:** nothing is applied; the draft stays in `drafts/`.
|
||||||
|
**If the support mailbox becomes readable:** re-sort the register by observed asks before step 3;
|
||||||
|
the top three may change, and the page order should follow the asks, not this draft's proxy ranking.
|
||||||
|
**If a `Must be true` line stops being true** before step 3 — a fix regresses, a line moves — the
|
||||||
|
entry goes back to the CTO, not onto the page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — verbatim source notes
|
||||||
|
|
||||||
|
| Date | Source | Verbatim |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-10-29 | Live `/faq` | "Does The Nook read my prompts or my code?" / "Do I need a Mac with a notch?" / "How much battery does it cost?" / "What permissions does it need?" / "Why macOS 14.2 or later?" / "Does it work offline?" |
|
||||||
|
| 2026-10-29 | FRE-326 description | “does it work with Cursor?” / “what happens when my agent is idle?” |
|
||||||
|
| 2026-10-29 | [FRE-329](/FRE/issues/FRE-329) CTO verdict | “Four entries ship as written, two ship after a wording fix… two (E3, E4) currently say something false to a buyer.” Every correction in §3 is that document's own replacement sentence. |
|
||||||
|
| 2026-10-08 | NOOK-121 | "Add /faq from real objections" — shipped the six live topics; refund dropped by Mike. |
|
||||||
|
| 2026-10-20 | NOOK-643 | "agents: Cursor via hooks.json" — "No permission hook is installed, deliberately: Cursor blocks the action when a permission hook prints nothing…" |
|
||||||
|
| 2026-10-21 | NOOK-707 | "agents: Cursor desktop chats bind to the shared extension-host pid and never end" |
|
||||||
|
| 2026-10-23 | NOOK-898 | "hook: kimi payloads fail to decode, so every Kimi session stays idle" |
|
||||||
|
| 2026-10-22 | NOOK-823 | "Codex terminal: sessions become idle or disappear prematurely" |
|
||||||
|
| 2026-10-15 | NOOK-417 | "Never render plain Idle for a session whose integration is dark" |
|
||||||
|
| 2026-10-15 | NOOK-415 | "Sessions whose integration predates the bridge credential freeze as Idle forever" |
|
||||||
|
| 2026-10-21 | NOOK-733 | "codex: a non-interactive session never runs an installed hook (0.163.0)" |
|
||||||
|
| 2026-10-20 | NOOK-648 | "opencode: uninstalling a pre-package install leaves its old nook.js link dangling" |
|
||||||
|
| 2026-10-20 | NOOK-685 | "tests: InstallerTests.clineHookFiles writes through the managed TaskComplete symlink and then expects that path to survive uninstall" |
|
||||||
|
| 2026-10-15 | NOOK-418 | "security: a custom agent's install target is never shown to, or approved by, the user" |
|
||||||
|
| 2026-10-17 | NOOK-568 | "helper: launch-time re-registration tears down an approved helper and leaves it unregistered" |
|
||||||
|
| 2026-10-13 | NOOK-302 | "terminal: a tmux session shows only 'tmux', and its outer terminal comes from whoever started the tmux server" |
|
||||||
|
| 2026-10-13 | NOOK-310 | "terminal: jump and send address tmux panes on the default server, ignoring the session's tmux socket" |
|
||||||
|
| 2026-10-22 | NOOK-788 | "pane: a remote reply inside a private tmux server resolves to nothing, and a pane id can be sent without its socket" |
|
||||||
|
| 2026-10-12 | NOOK-290 | "Compact island: weather slot clips mid-glyph on non-notch displays (no text cap)" |
|
||||||
|
| 2026-10-12 | NOOK-291 | "Compact island: non-notch pill is 36 pt wider than its slot row (padding double-counted in pillWidth)" |
|
||||||
|
| 2026-10-14 | NOOK-386 | "island: hide the empty pill under the notch (or above the top edge) and keep a narrower hover trigger" |
|
||||||
|
| 2026-10-23 | NOOK-850 | "Nook: the idle app does work nothing asked for (CPU wins from a live profile)" |
|
||||||
|
| 2026-10-23 | NOOK-851 | "island: a 30 fps timeline ticks the fan icon even when the fan is idle" |
|
||||||
|
| 2026-10-23 | NOOK-877 | "island: compact Nook still costs too much CPU at rest (round two, A/B per fix)" |
|
||||||
|
| 2026-10-23 | NOOK-881 | "A macOS 14.0/14.1 Mac cannot launch the app at all: dyld dies on a strongly linked CATapDescription overlay symbol" |
|
||||||
|
| 2026-10-16 | NOOK-494 | "iOS: the main list gives no sign the phone is offline from the Mac" |
|
||||||
|
| 2026-10-20 | NOOK-682 | "licensing: a new user can be blocked at launch instead of getting the 14-day trial" |
|
||||||
|
| 2026-10-20 | NOOK-684 | "licensing: parseISO rejects the server's fractional-second trial timestamp, so an expired server trial resets to 14 days" |
|
||||||
|
| 2026-10-08 | NOOK-60 | "Capture email at trial start" |
|
||||||
|
|
||||||
|
## Appendix B — frequency method
|
||||||
|
|
||||||
|
- **Live questions:** one per entry that quotes the live page. There are six, and they are the only
|
||||||
|
questions on the page today.
|
||||||
|
- **Board questions:** the two examples in FRE-326. They are not customer quotes; they are the
|
||||||
|
board's own words for the purchase-blocking objections this issue was created to answer.
|
||||||
|
- **Report counts:** the number of distinct NOOK issues I read whose subject or resolution directly
|
||||||
|
produced the objection, listed by identifier and date in §2 and Appendix A. I did not use a raw
|
||||||
|
keyword count: "agent" appears in 344 issues and "privacy" in 177, which says nothing about what
|
||||||
|
buyers ask. The counts are deliberately small and inspectable.
|
||||||
|
- **No ask frequency, no percentages.** The first thing a real support corpus changes is the order,
|
||||||
|
not the wording.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Draft ends. Nothing here is published. The page file stays unchanged until the sponsor approves this
|
||||||
|
revision and a human merges it.*
|
||||||
81
marketing/drafts/faq-page.patch
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
diff --git a/src/routes/nook/faq.tsx b/src/routes/nook/faq.tsx
|
||||||
|
index 03d135a..bc3adef 100644
|
||||||
|
--- a/src/routes/nook/faq.tsx
|
||||||
|
+++ b/src/routes/nook/faq.tsx
|
||||||
|
@@ -1,8 +1,7 @@
|
||||||
|
/**
|
||||||
|
* `/faq` on the Nook subdomain — answers to the objections that actually
|
||||||
|
- * stop a purchase. Privacy scoping follows marketing/positioning.md rule 3:
|
||||||
|
- * "no prompt or code content leaves the device" (telemetry and the trial
|
||||||
|
- * email do leave — never claim "no data leaves the device").
|
||||||
|
+ * stop a purchase. Privacy is deliberately absent until the egress
|
||||||
|
+ * inventory and /privacy make an honest answer possible.
|
||||||
|
*/
|
||||||
|
import { For } from "solid-js";
|
||||||
|
import { createSignal, Show } from "solid-js";
|
||||||
|
@@ -17,34 +16,46 @@ interface FaqEntry {
|
||||||
|
|
||||||
|
const FAQ: FaqEntry[] = [
|
||||||
|
{
|
||||||
|
- question: "Does The Nook read my prompts or my code?",
|
||||||
|
+ question:
|
||||||
|
+ "Does it work with my agent — and can I approve from the notch, or only watch?",
|
||||||
|
answer:
|
||||||
|
- "No. The hooks and plugins that connect your agents to The Nook send session metadata only — which agent is running, which session, which event happened, which terminal it lives in. No prompt text and no code content leaves your device. Two things do leave the device, and we'd rather say so plainly: anonymous usage telemetry, and your email address if you choose to give it at trial start. That's the whole list."
|
||||||
|
+ "It depends on the agent, and Cursor is the useful example. Cursor is watched, not gated: its sessions, finished turns and errors appear in the island, but when Cursor asks for permission the prompt stays in Cursor. That is deliberate — Cursor blocks the action when a permission hook runs and prints nothing, so gating it would override Cursor's own prompts. Other agents do hand the decision to The Nook, so a tool call waits and you tap Allow or Deny from the card. One limit: cursor-agent -p one-shot runs send no turn end, so they appear but do not get a completion card."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
- question: "Do I need a Mac with a notch?",
|
||||||
|
+ question: "What happens when my agent is idle?",
|
||||||
|
answer:
|
||||||
|
- "No. On a notched MacBook the island sits in the notch. On machines and displays without one, it anchors to the display your cursor is on, so a desktop setup with external monitors gets the same treatment. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
||||||
|
+ "Idle means the last turn is over, or the session has not produced anything yet — not that the process is gone. A running process is liveness, not work: a just-opened agent window can show idle until you type. When an agent finishes a turn with a final message, the row stays green for ten minutes, then goes gray. A stale row is different: it means The Nook's integration cannot report for that session, and the row says so instead of pretending it is idle. Nothing is lost when a row goes idle — the next prompt brings it back and flips it to running. The row itself leaves the island 15 minutes after the turn (5 if the turn ended without a reply), so a long-quiet session reappears on its next event instead of sitting there. If a session that is obviously working says idle, that is a bug; tell us."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
- question: "How much battery does it cost?",
|
||||||
|
+ question:
|
||||||
|
+ "What permissions does it need — and what does it write into my agent's config?",
|
||||||
|
+ answer:
|
||||||
|
+ "The app itself runs unprivileged. Fan control and charge limiting go through a small helper that macOS requires to run as root for hardware access; the app registers it on first use and takes you to System Settings › General › Login Items & Extensions, where you switch it on once. For each agent it adds what that agent needs — a hook group per event it listens to, or one plugin link — inside the agent's own config file or directory. It keeps its own files under ~/.nook — the hook binary and one plugin copy per agent — and adds only its own entries to that agent's config. A hook entry you added yourself in the same file survives install and uninstall, and it only touches the records and switches it created. Uninstall removes exactly what it wrote — and switches off what it turned on — with no leftover links or orphaned files."
|
||||||
|
+ },
|
||||||
|
+ {
|
||||||
|
+ question: "Does it work with my terminal, including tmux?",
|
||||||
|
answer:
|
||||||
|
- "The Nook is a small native UI, not a background agent — it watches the agents you're already running; it doesn't run anything itself. The heaviest thing it does is read and write your Mac's fan and charging controllers, which is a lightweight sensor read. If you're running enough coding agents to need it, those agents dwarf the app's own draw."
|
||||||
|
+ "Yes — it recognises Terminal.app, iTerm2, Warp, Ghostty, kitty, WezTerm and tmux. Four of those take a typed reply; the rest are watched. A session is tied to the terminal that started it. Jump lands in the right window for Terminal.app, iTerm2, kitty and any tmux pane — in tmux, the right pane; on Warp, WezTerm and Ghostty it brings the app forward instead. Replies type straight back into Terminal.app, iTerm2, kitty and any tmux pane; on Warp, WezTerm and Ghostty you answer in the app's own window, and Alacritty takes no typed reply from another app at all."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
- question: "What permissions does it need?",
|
||||||
|
+ question: "Do I need a Mac with a notch?",
|
||||||
|
+ answer:
|
||||||
|
+ "No. On a notched MacBook the island sits in the notch. On machines and displays without one, it sits at the top centre of the display — the same pill with the same slots. You get one island on every display it is turned on for (all connected displays on first launch), each anchored to the top of its own screen; Settings › Displays trims that list, and the pointer only decides which display's floating controls are live. Everything inside the island — agent cards, fan control, the stats strip — works identically either way."
|
||||||
|
+ },
|
||||||
|
+ {
|
||||||
|
+ question: "How much battery does it cost?",
|
||||||
|
answer:
|
||||||
|
- "The app itself runs unprivileged. Fan control and charge limiting go through a small helper component that macOS requires to run as root for hardware access — the app installs it on first use and you approve it once with your password. The hooks live in each agent's own config directory; you can inspect or remove every file it writes."
|
||||||
|
+ "It is a native app, not a background agent. It watches the agents you already run and never runs them in the background, so their draw is the number that matters. We do not have a published battery measurement for The Nook alone; when we do, it will be a measurement, not an estimate. If it costs more than it should on your Mac, that is a bug — tell us."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Why macOS 14.2 or later?",
|
||||||
|
answer:
|
||||||
|
- "The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma)."
|
||||||
|
+ "The audio mixer listens to each app's own sound stream, and Apple only shipped that API in macOS 14.2 (Sonoma). The app links against it, so 14.0 and 14.1 cannot launch it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Does it work offline?",
|
||||||
|
answer:
|
||||||
|
- "Yes. Everything The Nook does — the agent grid, permission cards, fan and thermal control — works with no network at all, and your agents' hooks talk to the app over a local socket. The network is only used for update checks, license activation, and the optional trial email."
|
||||||
|
+ "The Mac app does. The island, agent cards, permission cards and fan control work with no network; the hooks talk to the app over a local socket. The network is used for update checks, licence activation, crash reports and the optional trial email, plus a few things only when you use them: weather (asked for through our relay, with the location you chose), the network panel's IP-and-ISP lookup, the speed test, the changelog in Settings, and a bug report if you send one. The phone link is the exception: when you are away from your Mac, the phone reaches it through our relay, so that part needs a connection."
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@@ -56,7 +67,7 @@ export default function NookFaqPage() {
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
|
<PageHead
|
||||||
|
title="Frequently asked questions"
|
||||||
|
- description="Does The Nook read your prompts? Do you need a notch? Battery cost, permissions, the macOS 14.2 requirement, offline use — answered straight."
|
||||||
|
+ description="Which agents can you approve from the notch? What does idle mean? Terminal and tmux support, permissions, the macOS 14.2 requirement, offline use — answered straight."
|
||||||
|
ogTitle="The Nook — FAQ"
|
||||||
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
40
marketing/drafts/positioning-privacy-withdrawal.patch
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
diff --git a/marketing/positioning.md b/marketing/positioning.md
|
||||||
|
index 1bc4973..26d6b04 100644
|
||||||
|
--- a/marketing/positioning.md
|
||||||
|
+++ b/marketing/positioning.md
|
||||||
|
@@ -53,13 +53,20 @@ pages, marketing copy, comparison tables, meta descriptions, emails, FAQs, all o
|
||||||
|
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
|
||||||
|
If a claim cannot be pointed at code or a test, it does not go in copy.
|
||||||
|
|
||||||
|
-### 3. Honest privacy scoping
|
||||||
|
+### 3. No privacy claim until the egress inventory exists (withdrawn 2026-10-29)
|
||||||
|
|
||||||
|
-Telemetry and the trial email **do** leave the device. The defensible claim is:
|
||||||
|
+The claim that stood here — "No prompt or code content leaves the device" — is **false at HEAD**
|
||||||
|
+and must not be restated in any copy until it is verified and replaced. It was backed by an
|
||||||
|
+argument about envelope metadata, which says nothing about what leaves the machine. Three things
|
||||||
|
+at HEAD contradict it: crash reporting initialises with `sendDefaultPii = true`; the licence call
|
||||||
|
+sends the hardware UUID and the device name; the phone relay carries sealed prompt and reply
|
||||||
|
+payloads. `/privacy` names the hardware UUID and the device name and says nothing about crash
|
||||||
|
+reporting with PII or about the relay.
|
||||||
|
|
||||||
|
-> **No prompt or code content leaves the device.**
|
||||||
|
-
|
||||||
|
-Never "no data leaves the device".
|
||||||
|
+So: write **no** privacy claim at all — not softened, not partial. "Telemetry and the trial email
|
||||||
|
+do leave the device" is true and insufficient; it is not a substitute. The replacement goes in
|
||||||
|
+only after FRE-333 lands an egress inventory and `/privacy` text that match what the app does.
|
||||||
|
+See FRE-326 (the `/faq` withdrawal) and FRE-333 (the inventory).
|
||||||
|
|
||||||
|
### 4. Sell what works today
|
||||||
|
|
||||||
|
@@ -87,8 +94,7 @@ nothing posts itself.
|
||||||
|
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
|
||||||
|
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
|
||||||
|
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
|
||||||
|
-| macOS 14+ | 27 source files use the `@Observable` framework |
|
||||||
|
-| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
|
||||||
|
+| macOS 14.2+ | the CoreAudio overlay symbol the mixer uses only exists from 14.2 (NOOK-881) |
|
||||||
|
|
||||||
|
## Held claims (not publishable)
|
||||||
|
|
||||||
92
marketing/drafts/privacy-page.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# `/privacy` — replacement copy, FRE-354 corrections applied 2026-11-04
|
||||||
|
|
||||||
|
**Status:** applied on branch `faq/from-objections-2026-10-29` (commit after `bb6cf27`), per the CTO's FRE-354 verdict — "apply the corrections above and the page is signed." The two copy decisions the verdict delegated to the CMO are taken: the **fuller iPhone paragraph** (its push-token evidence row is recorded in the evidence table below and routed to FRE-333's owner to append to the signed inventory), and the **full (c) block with the LAN/tailnet sentence**, which pulls the corresponding Section 3 clause in. Nothing here is published; the sponsor's merge + push on card [`392a8465`](/FRE/issues/FRE-326#interaction-392a8465) is still the deploy step.
|
||||||
|
**Sequencing fact, updated 2026-11-06:** the contingency has **fired**. `v0.15.1` (tagged 2026-11-04, first tag containing FRE-355's merge `6e74b261`) and `v0.15.2` are live on the appcast (`freno.me/api/TheNook/appcast.xml`, newest item 0.15.2). Shipped behaviour when enabled: crash/error reports, session pings (`enableAutoSessionTracking` untouched), traces at 0.1, and **Sentry also receives the connection's IP address** (`sendDefaultPii = false` is the SDK default and does not suppress it; only the Sentry project's *Prevent Storing of IP Addresses* switch does — FRE-1480). Paragraph (b) and the iPhone paragraph are therefore swapped to the opt-in form in this commit, with two corrections against the pre-written swap text: pings and traces are named (the contingency's own completeness warning), and the IP sentence is kept. Releases 0.3.0 through 0.15.0 started Sentry on every launch with no off switch; the page describes the current release.
|
||||||
|
**Patch:** `marketing/drafts/privacy-page.patch` — applies cleanly to `c1c498e` (`git apply --check`, verified 2026-10-29; parse-checked with esbuild, not the site suite).
|
||||||
|
**Evidence base:** [FRE-333 `egress-inventory`](/FRE/issues/FRE-333#document-egress-inventory) rev 1, eleven destinations, file:line per row, read at `The-Nook` `3c3ba7ee`; and the same document's §G, which proposed a page text. This patch is the §G text **corrected against the inventory** — the differences are listed below, each with the row that forces it.
|
||||||
|
**Why this exists:** [FRE-326](/FRE/issues/FRE-326) + [FRE-334](/FRE/issues/FRE-334#document-decision) recorded option A — the privacy claim is withdrawn everywhere, and no privacy claim returns until the inventory exists, `/privacy` matches it, the CTO verifies both, and the sponsor approves the replacement copy. The inventory landed on 2026-10-29. This is the page half.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Claim this page carries | None. It is a disclosure page, not a claim: it states what the app sends and what it does not. The one favourable sentence is (j), which is the CTO's FRE-322 §2 code fact and which he may still decline. |
|
||||||
|
| Audience | Someone deciding whether to pay $10 for a tool that reads their prompts. They read this page *before* the FAQ. |
|
||||||
|
| Channel | `nook.freno.me/privacy`, one page |
|
||||||
|
| Owner | CMO (copy + commit), CTO (verification), sponsor (approval of the replacement copy), Chief of Staff (publish switch) |
|
||||||
|
| Date | Prepared 2026-10-29. Applied 2026-11-04 on the branch with the FRE-354 corrections; the contingency (b)/iPhone swap applied 2026-11-06. `Last Updated` on the page now reads November 6, 2026 — re-check it against the actual deploy date at push. Not a public date. |
|
||||||
|
| What has to be true before it ships | 1) CTO signs the page against `The-Nook` HEAD, paragraph by paragraph — [FRE-354](/FRE/issues/FRE-354), due 2026-11-02. 2) Sponsor accepts the replacement copy. 3) The Sentry paragraph matches the build being deployed — see *Contingency*. 4) It deploys in the same push as the FAQ withdrawal; a page that discloses Sentry next to a FAQ that still says "anonymous usage telemetry" is worse than either alone. |
|
||||||
|
|
||||||
|
## What changed against the live page
|
||||||
|
|
||||||
|
The live page (fetched 2026-10-29T09:2xZ and again 09:4xZ, HTTP 200, `Last Updated: August 26, 2026`, unchanged) says the app "runs locally on your device and sends only the minimal data required for trial and licensing" and lists four things. Against the inventory that is wrong in two directions: it **omits** Sentry (crash reports with IP address, every launch, no off switch), the relay and its install ID, the licence key crossing to the relay, sealed phone payloads, weather coordinates going to the relay *and* to Apple, the GeoIP third parties, the speed test, the changelog fetch, bug-report contents and update checks. It also calls the hardware UUID "masked", which the code contradicts — it sends the raw `IOPlatformUUID`.
|
||||||
|
|
||||||
|
The replacement is organised by *connection*, not by *data type*, because that is how a reader checks it: eleven rows in the inventory, ten of them reachable from the page, one (Sentry) prominent because it is the only one a reader can turn off entirely and still miss in older builds. On the current Mac release (`v0.15.1`+) it is opt-in and off by default; the iPhone build in TestFlight (`ios-v0.3.2`, 2026-10-22) predates it. See *Contingency*.
|
||||||
|
|
||||||
|
## Differences from FRE-333 §G, and what forces each
|
||||||
|
|
||||||
|
§G is the Security Engineer's proposed text; this patch is that text corrected against §G's own inventory. Six deliberate differences, none of them stylistic:
|
||||||
|
|
||||||
|
| # | §G says | This patch says | Why |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Opening: "processed on your device and are never sent to us" | Opening drops "never sent to us"; (c) and (j) state the two paths agent data does take | Sealed frames still transit our relay (§A.5, §B), and a remote machine carries them by design (§C). §G's own §B says the relay routes them. "Never sent to us" is the class of sentence that got the FAQ answer withdrawn. |
|
||||||
|
| 2 | (d) Weather: relay registration implied only with Phone | (d) adds that Weather registers **even with Phone off, and on a licensed Mac that registration presents the license key** | §A.3 + §D.2. `SidecarController.weather()` routes through the same `keystore.relayCredential(licenseKey:)` as the phone path (`SidecarController.swift:269-279`, `SidecarRelayAPI.swift:298-312`). §G under-disclosed it. |
|
||||||
|
| 3 | (a) trial/activate/status only | (a) adds `/deactivate` (key + UUID) and the trial email (email + UUID, sent once) | §A.2, `/D.4`. §G omitted both endpoints. |
|
||||||
|
| 4 | (i) "The email you give at trial start or checkout delivers your license key" | (i) checkout delivers the key; a trial email is one reminder before the trial ends | `LicenseService.submitTrialEmail` + `OnboardingExtrasStep` say the trial email is an expiry reminder; the key is issued at purchase. §G's sentence promised a key that the trial email does not deliver. |
|
||||||
|
| 5 | (b) Sentry: opt-in, "off unless you turn it on" | (b) is the opt-in form, applied 2026-11-06: off unless enabled in Settings, and it names session pings and the trace sample; **the IP sentence, kept** | The contingency fired with `v0.15.1`. Corrections against the pre-written swap text: pings/traces named because they still flow when enabled; IP kept because `sendDefaultPii = false` does not stop Sentry receiving the connection IP (FRE-1480); the Contingency text already said so. |
|
||||||
|
| 6 | No (j) | (j) carries the CTO's FRE-322 §2 code fact, with the remote-machine exception | Not required for truth; it is the one favourable sentence, and the CTO may decline it. Delete the block if he does. |
|
||||||
|
|
||||||
|
Two facts the inventory corrected and the page does not repeat wrongly: Sparkle's automatic checks are **off by default** here (`UpdateManager` owns the schedule; FRE-322 §2.4 said "on by default" and is stale on this row), and the UUID is **raw, not masked**.
|
||||||
|
|
||||||
|
## Paragraph-level evidence
|
||||||
|
|
||||||
|
| Paragraph | What it now says | Evidence at `3c3ba7ee` |
|
||||||
|
|---|---|---|
|
||||||
|
| (a) Licensing/trial | raw UUID + device name; every launch while trialing; key-only once licensed; deactivate sends key + UUID; trial email sends email + UUID once; trial/status fail open, activation needs a connection once | `LicenseService.swift:300-313` (`/trial`), `:271-284` (`/status`), `:247-262` (`/activate`), `:286-302` (`/deactivate`), `:219-231` (`/trial-email`) |
|
||||||
|
| (b) Crash reporting | opt-in, **off by default**; when enabled: crash/error reports, session pings, traces at 0.1; **Sentry also receives the IP address** (`Options.swift:267-275` at 9.26.1: `sendDefaultPii = false` is the default and does not suppress it); no prompts or code | `Sources/NookApp/main.swift` `startIfEnabled` — `sendDefaultPii = false` (`policy.sendDefaultPii`), `tracesSampleRate = 0.1` while on (`CrashReporting.onTracesSampleRate`); `CrashReporting.registerDefaults` pins `enabledKey: false`; evidence read 2026-11-06, IP row per FRE-1480 |
|
||||||
|
| (c) Phone relay | off by default; relay is the default path, LAN/tailnet direct pairing available; install ID + license key at registration; sealed payloads; relay sees device IDs/names/platforms, timing and volume, not content | `SidecarRelayAPI.swift:144-153`, `SidecarPayload.swift:484-490`, `RemoteEndpoint.swift:14-24`, `docs/sidecar-frames.md` (sealing), inventory §B |
|
||||||
|
| (d) Weather | 0.1° grid snapped **on the Mac**; registers with the relay even with the phone off, licence key included on a licensed Mac; Apple sees place lookups and position | `WeatherForecast.swift:25-30`, `SidecarController.swift:269-279`, `SidecarRelayAPI.swift:298-312`, `WeatherLocation.swift:14-77`, `:89-143` |
|
||||||
|
| (e) Network panel | third-party IP lookup sees your IP; speed test uploads zeros only | `NetworkInfoService.swift:82,276`, `SpeedTest.swift:10-11,36` |
|
||||||
|
| (f) Update checks | ship **off**; turning them on fetches `freno.me`, app version + IP | `UpdateManager.swift:48-55` (`automaticallyChecksForUpdates = false`, default key false) |
|
||||||
|
| (g) Changelog | fetches release notes from `freno.me` | `ChangelogRelease.swift:75-78` |
|
||||||
|
| (h) Bug reports | user-sent only; machine snapshot listed in full | `BugReportClient.swift:44-94`, `BugReportSnapshot.swift:17-36` |
|
||||||
|
| (i) Email/payment | checkout email delivers the key; trial email is a reminder; Stripe | `LicenseService.swift:219-231`, `OnboardingExtrasStep.swift:86-110` |
|
||||||
|
| (j) What does not leave your Mac | hook and bridge are not a cloud service; helper has no networking; two paths carry agent data across the network, both user-switched: remote machines over SSH or the tailnet, and the sealed phone relay | `hook/transport.go:143` (`resolveBridge`), `BridgeBindPlan.swift:9-23`, `RemoteEndpoint.swift:14-24`, inventory §B + §C |
|
||||||
|
| iPhone companion (TestFlight beta) | the TestFlight build (`ios-v0.3.2` = `2e322da5`, 2026-10-22) predates FRE-355: unconditional start, `sendDefaultPii = true`, traces 1.0, no off switch (`ios/AgentNookMobile/App.swift:13-20`); the opt-in code exists at `v0.15.2` but no `ios-v*` tag carries it; other traffic is the phone connection in (c) plus the APNs push token given to the relay | `RelayLink.pair` `RelayLink.swift:104-118`, `updatePushToken` `:226-232` — row added per FRE-354, TestFlight provenance per FRE-1480; append to the FRE-333 inventory |
|
||||||
|
|
||||||
|
## Contingency — the Sentry paragraph is the only one that moves
|
||||||
|
|
||||||
|
**FIRED 2026-11-06 — applied on the branch.** `v0.15.1` is the first release containing FRE-355 and is live on the appcast. The swap below was applied with the corrections recorded in the sequencing paragraph at the top of this document (pings and traces named; IP sentence kept — the pre-written text below had it, and dropping it was wrong; FRE-1480). The original text is kept for the record:
|
||||||
|
|
||||||
|
(b) as committed describes today's build. The inventory's recommendation (§E.1) is an opt-in setting, default off, dropping `sendDefaultPii` and cutting `tracesSampleRate`, on Mac **and** iOS. If that ships, (b) is replaced in the same commit that changes the behaviour:
|
||||||
|
|
||||||
|
> **(b) Crash and error reporting — off unless you turn it on.** If you enable crash reporting in Settings, The Nook sends crash and error reports to Sentry, our crash-reporting processor. Reports include the app version, macOS version, Mac model and what the app was doing when it failed, and Sentry also receives your IP address. They never include your prompts or code.
|
||||||
|
|
||||||
|
**Sequence call, and who owns it.** Shipping (b)-with-no-off-switch is honest, but it puts "no off switch" in front of a buyer during launch week. The better order is the build change first, then this page — that is a build/priority call, not mine: it is proposed on the board as [FRE-355](/FRE/issues/FRE-355) (Senior Engineer, date proposed 2026-11-06, CTO to confirm), and if the CTO says the build change cannot land before the page, the page ships with today's text. Publishing the truthful-today version is never *blocked* by that choice; the *preferred* order is the other way.
|
||||||
|
|
||||||
|
**This is why (b) must not be softened on the way out.** If the opt-in setting ships but `tracesSampleRate` or session tracking stays on, (b)'s opt-in form is incomplete again — it names only "crash and error reports" while performance traces and session pings still flow. The paragraph and the build change move together or neither does. (FRE-354 rewrote this warning: the original named a macOS username that sentry-cocoa never sends.)
|
||||||
|
|
||||||
|
## If the CTO declines paragraph (j)
|
||||||
|
|
||||||
|
(j) is the only favourable sentence on the page and the only one carrying a CTO verdict rather than an inventory row. It is [FRE-322 §2](/FRE/issues/FRE-322#document-verdict) — *"verified as a code fact, not publishable yet… publishing it before the disclosure work lands would put a true sentence next to false ones."* The disclosure work is this page. If the CTO still declines it, delete the whole (j) block; nothing else on the page depends on it, and the page stays complete and true.
|
||||||
|
|
||||||
|
## Open questions for the CTO's read
|
||||||
|
|
||||||
|
1. **The iOS app.** §D.10: the iOS target starts the same Sentry DSN with the same `sendDefaultPii` (`ios/AgentNookMobile/App.swift:12-20`). This page says "runs on your Mac". Is any iOS build in users' hands? If yes, the page needs one sentence (or the build needs the same opt-in); if no, say so and the page stays Mac-only.
|
||||||
|
2. **Weather registration without a licence key.** §E.3 suggests decoupling weather registration from the licence key. If that ships before the page, (d) loses its licence-key clause; if after, the page is true today.
|
||||||
|
3. **(j)** — publish, or cut.
|
||||||
|
|
||||||
|
## Apply checklist — what the apply commit must do, in order
|
||||||
|
|
||||||
|
1. Re-read the live page and the inventory's HEAD; if either moved, re-derive (a)–(j) before applying. The inventory is pinned to `3c3ba7ee`.
|
||||||
|
2. Bump `Last Updated` to the **deploy** date — the patch carries 2026-10-29 as prepared, and that line is the one place the date must change at apply.
|
||||||
|
3. If the Sentry opt-in build change is in the same release, swap (b) for the *Contingency* paragraph — one hunk.
|
||||||
|
4. Apply `privacy-page.patch` and the FRE-326 `faq-page.patch` + `positioning-privacy-withdrawal.patch` in the **same** commit, so no page states the old claim while another corrects it.
|
||||||
|
5. Run the site's suite — `bun test` — which this host cannot do (no `bun`; `node_modules` present, `package.json` test script is `bun test`). A parse/transpile check with esbuild is **not** that suite and must not be recorded as one.
|
||||||
|
|
||||||
|
## Rules this page obeys
|
||||||
|
|
||||||
|
- No competitor named (category nouns only, and none needed here).
|
||||||
|
- No claim without a row in the inventory. Where the inventory has no row, the page says nothing.
|
||||||
|
- No roadmap promise: the page never says a feature or fix is "coming", so the Sentry paragraph does not promise an off switch.
|
||||||
|
- No price, no discount, no date, no launch language.
|
||||||
|
- It is a correction of an existing page, not new marketing; nothing was added to sell anything.
|
||||||
|
- It does not add a `/faq` privacy answer. That answer's return is gated separately ([FRE-326](/FRE/issues/FRE-326) `privacy-return-path`).
|
||||||
237
marketing/drafts/privacy-page.patch
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
diff --git a/src/routes/nook/privacy.tsx b/src/routes/nook/privacy.tsx
|
||||||
|
index da0cc1d..db27d8c 100644
|
||||||
|
--- a/src/routes/nook/privacy.tsx
|
||||||
|
+++ b/src/routes/nook/privacy.tsx
|
||||||
|
@@ -1,9 +1,38 @@
|
||||||
|
/**
|
||||||
|
* The Nook privacy policy — `nook.freno.me/privacy`.
|
||||||
|
*
|
||||||
|
- * Reflects the actual data The Nook sends when licensing: a masked hardware
|
||||||
|
- * UUID + device name for trial/activation limits, the email used for purchase,
|
||||||
|
- * and Stripe payment processing.
|
||||||
|
+ * Every statement on this page is traced to the egress inventory at
|
||||||
|
+ * `The-Nook` HEAD (FRE-333, `document-egress-inventory`): eleven internet
|
||||||
|
+ * destinations, file:line evidence per row. When the app's egress changes,
|
||||||
|
+ * this page changes in the same commit — not later.
|
||||||
|
+ *
|
||||||
|
+ * The crash-reporting paragraphs ((b) and the iPhone companion) describe the
|
||||||
|
+ * build users run today: crash reporting is opt-in, off by default, on
|
||||||
|
+ * releases v0.15.1 and later — FRE-355 (merge `6e74b261`) first shipped in
|
||||||
|
+ * `v0.15.1` (2026-11-04), and the live appcast serves `v0.15.2`. When enabled,
|
||||||
|
+ * Sentry receives crash and error reports, session pings, and performance
|
||||||
|
+ * traces at a 0.1 sample rate (`tracesSampleRate = 0.1`), and it also receives
|
||||||
|
+ * the connection's IP address (`sendDefaultPii = false` is the SDK default and
|
||||||
|
+ * does not suppress it — sentry-cocoa 9.26.1 sets `infer_ip = auto` for Cocoa,
|
||||||
|
+ * and only the Sentry project's *Prevent Storing of IP Addresses* switch stops
|
||||||
|
+ * that; FRE-1480). Releases 0.3.0 through 0.15.0 started Sentry on every
|
||||||
|
+ * launch with `sendDefaultPii = true` and no off switch; this page describes
|
||||||
|
+ * the current release. The iPhone paragraph describes `ios-v0.3.2`
|
||||||
|
+ * (2026-10-22, the build in TestFlight) until an `ios-v*` tag carries
|
||||||
|
+ * FRE-355's opt-in code.
|
||||||
|
+ *
|
||||||
|
+ * The *Contingency* in `marketing/drafts/privacy-page.md` fired with `v0.15.1`
|
||||||
|
+ * and is applied in this commit, with two corrections against the pre-written
|
||||||
|
+ * swap text: the shipped build still sends session pings and traces while
|
||||||
|
+ * enabled, so they are named (the contingency's own completeness warning), and
|
||||||
|
+ * the IP sentence is kept — the pre-written swap text already had it, and
|
||||||
|
+ * `sendDefaultPii = false` does not stop Sentry receiving the IP (FRE-1480).
|
||||||
|
+ *
|
||||||
|
+ * Corrected against the CTO's /privacy verdict (FRE-354): no macOS username
|
||||||
|
+ * from Sentry (sendDefaultPii sets the IP only), session pings named, (a)
|
||||||
|
+ * fail-open scoped, (c) relay-path qualified with the direct-pair sentence,
|
||||||
|
+ * (j) "not a cloud service" with the tailnet path, the iPhone paragraph added
|
||||||
|
+ * (TestFlight beta is in users' hands), and section 3 says "content".
|
||||||
|
*/
|
||||||
|
import { buildMainSiteUrl } from "~/lib/subdomain-url";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
@@ -21,39 +50,139 @@ export default function NookPrivacyPolicy() {
|
||||||
|
<SubdomainHeader />
|
||||||
|
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||||
|
<div class="py-4 text-xl">The Nook's Privacy Policy</div>
|
||||||
|
- <div class="py-2">Last Updated: August 26, 2026</div>
|
||||||
|
+ <div class="py-2">Last Updated: November 6, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
- Welcome to The Nook ('We', 'Us', 'Our').
|
||||||
|
- Your privacy is important to us. This policy explains what we collect,
|
||||||
|
- why, and how it is handled. The app itself runs locally on your device
|
||||||
|
- and sends only the minimal data required for trial and licensing.
|
||||||
|
+ The Nook runs on your Mac, and the iPhone and Apple Watch companion
|
||||||
|
+ is in TestFlight beta. Your prompts, your code and your agent
|
||||||
|
+ sessions are processed on your own devices. This page lists every
|
||||||
|
+ connection the apps make over the internet — and the local-network
|
||||||
|
+ and tailnet paths the phone can use — what each one sends, and
|
||||||
|
+ whether you can turn it off.
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
- <span class="-ml-4 pr-2">1.</span> Data We Collect
|
||||||
|
+ <span class="-ml-4 pr-2">1.</span> What leaves your devices
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
- <div class="-ml-6">(a) Device hardware UUID:</div> When you start
|
||||||
|
- a trial or activate a license, The Nook sends a hardware UUID
|
||||||
|
- from your Mac. It is used solely to enforce the 14-day trial and
|
||||||
|
- the 3-device activation limit, and to prevent resetting the
|
||||||
|
- trial by reinstalling the app.
|
||||||
|
+ <div class="-ml-6">(a) Licensing and trial:</div> When you start
|
||||||
|
+ a trial or activate a license, The Nook sends your Mac's
|
||||||
|
+ hardware UUID and device name to our licensing server
|
||||||
|
+ (freno.me). The UUID enforces the 14-day trial and the 3-device
|
||||||
|
+ activation limit and stops a trial being reset by reinstalling
|
||||||
|
+ the app; the device name is stored with the activation so the
|
||||||
|
+ Mac can be identified in your license records. While you are on
|
||||||
|
+ the trial, this is sent at each launch. Once you are licensed,
|
||||||
|
+ each launch re-checks only that your key has not been revoked.
|
||||||
|
+ Deactivating a Mac sends the license key and that Mac's
|
||||||
|
+ UUID. If you add an email during the trial, that request
|
||||||
|
+ carries the email and the UUID, and it is sent once. The trial
|
||||||
|
+ and status checks fail open: with no connection, an unexpired
|
||||||
|
+ trial or a stored license keeps working, because license
|
||||||
|
+ verification itself is offline. Activating a new license needs
|
||||||
|
+ a connection once.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="pb-2">
|
||||||
|
+ <div class="-ml-6">(b) Crash and error reporting — off unless
|
||||||
|
+ you turn it on:</div> Crash reporting is off by default and
|
||||||
|
+ runs only if you enable it in Settings. When it is on, The Nook
|
||||||
|
+ sends crash reports, error events, hang reports, session pings,
|
||||||
|
+ and a sample of performance traces to Sentry, our
|
||||||
|
+ crash-reporting processor. Each report carries the app version,
|
||||||
|
+ the macOS version, the Mac model and what the app was doing
|
||||||
|
+ when it failed. Sentry also receives your IP address. They
|
||||||
|
+ never include your prompts, your code or your session content.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="pb-2">
|
||||||
|
+ <div class="-ml-6">(c) Phone connection:</div> The phone
|
||||||
|
+ connection ships off and is yours to enable. With the default
|
||||||
|
+ relay path, turning it on registers your Mac with our relay
|
||||||
|
+ (nook-relay.freno.me) using a random install ID, and a licensed
|
||||||
|
+ Mac presents its license key once at registration. Everything
|
||||||
|
+ your Mac and your phone then exchange — session updates,
|
||||||
|
+ approvals, replies — is encrypted end-to-end: the relay routes
|
||||||
|
+ it and cannot read it. On the relay path, the relay can see
|
||||||
|
+ which devices are connected — their IDs, names and platforms —
|
||||||
|
+ when, and how much traffic they exchange.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="pb-2">
|
||||||
|
+ You can also pair over your LAN or tailnet instead; those paths
|
||||||
|
+ carry the same sealed traffic and do not use the relay.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="pb-2">
|
||||||
|
+ <div class="-ml-6">(d) Weather:</div> If you add the Weather
|
||||||
|
+ module, your Mac asks our relay for forecasts using coordinates
|
||||||
|
+ rounded to a 0.1° grid — about 11 km — and the rounding happens
|
||||||
|
+ on your Mac before anything is sent. The Weather module
|
||||||
|
+ registers with the relay for this even if you never turn on the
|
||||||
|
+ phone connection, and on a licensed Mac that registration
|
||||||
|
+ presents the license key, exactly as the phone connection's
|
||||||
|
+ does. "My Location" asks macOS for your
|
||||||
|
+ approximate position, and naming or searching for a place uses
|
||||||
|
+ Apple's Maps service, so Apple sees those lookups. Neither
|
||||||
|
+ feature sends us your precise location.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="pb-2">
|
||||||
|
+ <div class="-ml-6">(e) The Network panel:</div> While the
|
||||||
|
+ Network panel's connection section is on screen, The Nook
|
||||||
|
+ asks a third-party IP-lookup service (get.geojs.io, with
|
||||||
|
+ ipwho.is as backup) what your public IP address and provider
|
||||||
|
+ are — that is how the panel can show them to you. Those services
|
||||||
|
+ necessarily see your IP address. The speed-test button measures
|
||||||
|
+ against Cloudflare's public speed endpoint and uploads only
|
||||||
|
+ zeros, never your data.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="pb-2">
|
||||||
|
+ <div class="-ml-6">(f) Update checks:</div> Automatic update
|
||||||
|
+ checks ship off. If you turn them on, or press "Check for
|
||||||
|
+ Updates", The Nook fetches the update feed from freno.me
|
||||||
|
+ and the request carries your app version. Like every request on
|
||||||
|
+ this page, it also arrives from your IP address.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="pb-2">
|
||||||
|
+ <div class="-ml-6">(g) Changelog:</div> Opening Settings ›
|
||||||
|
+ Changelog fetches the release notes from freno.me.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="pb-2">
|
||||||
|
+ <div class="-ml-6">(h) Bug reports:</div> only what you choose
|
||||||
|
+ to send. If you write a bug report, you decide what it says.
|
||||||
|
+ Bug reports also include a machine snapshot — macOS version, Mac
|
||||||
|
+ model, CPU, memory, free disk, locale, and your displays'
|
||||||
|
+ names and resolutions — so the problem can be reproduced. The
|
||||||
|
+ optional contact email is used only to reply to you.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
- <div class="-ml-6">(b) Device name:</div> The Nook sends your
|
||||||
|
- Mac's name so you can recognize the device in your license
|
||||||
|
- activations. It is not used for any other purpose.
|
||||||
|
+ <div class="-ml-6">(i) Your email and payment:</div> The email
|
||||||
|
+ you give at checkout delivers your license key and looks up your
|
||||||
|
+ order. An email you add during the trial is used for one
|
||||||
|
+ reminder before the trial ends. Payments are processed by
|
||||||
|
+ Stripe; we never see or store your card details.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
- <div class="-ml-6">(c) Email:</div> The email you provide at
|
||||||
|
- checkout is used to deliver your license key and to look up your
|
||||||
|
- order.
|
||||||
|
+ <div class="-ml-6">(j) What does not leave your Mac:</div> The
|
||||||
|
+ hook and the bridge that connect your agents to The Nook are
|
||||||
|
+ not a cloud service: the hook dials the bridge on your Mac, and
|
||||||
|
+ the bridge only listens. Two paths carry agent data across the
|
||||||
|
+ network, and both are switched on by you: if you add a remote
|
||||||
|
+ machine, the hook on that machine dials this Mac over SSH or
|
||||||
|
+ your tailnet; and if you use the phone connection, the session
|
||||||
|
+ updates, approvals and replies described in (c) travel through
|
||||||
|
+ the relay, sealed end to end. The privileged helper that
|
||||||
|
+ controls fans and charging has no networking code at all.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
- <div class="-ml-6">(d) Payment:</div> Payments are processed by
|
||||||
|
- Stripe. We never see or store your card details.
|
||||||
|
+ <div class="-ml-6">The iPhone companion.</div> The iPhone and
|
||||||
|
+ Apple Watch app is in TestFlight beta. The build in TestFlight
|
||||||
|
+ starts Sentry on every launch, with no setting that turns it
|
||||||
|
+ off: it sends crash, error and hang reports, session pings,
|
||||||
|
+ performance traces, the app version, the iOS version, the
|
||||||
|
+ iPhone model and your IP address. Those reports never include
|
||||||
|
+ your prompts, code or session content.
|
||||||
|
+ Its other network traffic is the phone connection in (c) — the
|
||||||
|
+ relay, or a direct LAN/tailnet link — plus, if you allow
|
||||||
|
+ notifications, the push token it gives the relay so Apple can
|
||||||
|
+ deliver them.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -63,9 +192,11 @@ export default function NookPrivacyPolicy() {
|
||||||
|
<span class="-ml-4 pr-2">2.</span> How We Use It
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
- Device identifiers, your email, and license records are used only
|
||||||
|
- to operate the trial, deliver purchases, and enforce the 3-device
|
||||||
|
- license. We do not use them for advertising or sell them to anyone.
|
||||||
|
+ Device identifiers, install IDs, your email and license records are
|
||||||
|
+ used only to operate the trial and the licenses, to run the relay
|
||||||
|
+ and the weather service, to answer bug reports, and to deliver
|
||||||
|
+ purchases. We do not use them for advertising and we do not sell
|
||||||
|
+ them to anyone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@@ -74,9 +205,13 @@ export default function NookPrivacyPolicy() {
|
||||||
|
<span class="-ml-4 pr-2">3.</span> Data Security
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
- License keys are signed and verified with Ed25519. The connection
|
||||||
|
- to our servers uses TLS. Your license key is stored in the macOS
|
||||||
|
- Keychain on your Mac.
|
||||||
|
+ License keys are signed and verified with Ed25519 and stored in
|
||||||
|
+ the macOS Keychain. The session content your Mac and phone
|
||||||
|
+ exchange is encrypted end-to-end with keys your devices generate
|
||||||
|
+ at pairing; our relay holds no key that can read it, though it
|
||||||
|
+ can read the routing metadata described in (c). Every internet
|
||||||
|
+ connection described on this page uses TLS; the direct
|
||||||
|
+ LAN/tailnet paths use the same end-to-end encryption.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
76
marketing/drafts/remote-ssh/copy.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Remote SSH copy — HELD, do not publish
|
||||||
|
|
||||||
|
**Status:** removed from the live site on 2026-10-29 in the FRE-323 correction.
|
||||||
|
**Owner:** CMO. **Verified by:** CTO, on the FRE-320/FRE-205 work.
|
||||||
|
**Restore only when:** FRE-320/FRE-205 ship a genuinely one-click install, the CTO
|
||||||
|
verifies that flow, and a recording shows a remote agent joining the island.
|
||||||
|
Until then this file is the only home for these sentences.
|
||||||
|
|
||||||
|
## What was removed
|
||||||
|
|
||||||
|
### 1. Landing collage tile
|
||||||
|
|
||||||
|
Source: `src/components/nook/FeatureCollage.tsx` (removed 2026-10-29).
|
||||||
|
|
||||||
|
```
|
||||||
|
kicker: Remote
|
||||||
|
title: Agents on other machines
|
||||||
|
body: Run agents on Linux boxes and SSH servers — one click installs the
|
||||||
|
nook-hook, and their sessions join the same island.
|
||||||
|
```
|
||||||
|
|
||||||
|
The mock attached to it showed two session rows whose agent chips named
|
||||||
|
`build-box` and `gpu-rig`.
|
||||||
|
|
||||||
|
### 2. Landing feature breakdown section
|
||||||
|
|
||||||
|
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
|
||||||
|
|
||||||
|
```
|
||||||
|
kicker: Remote
|
||||||
|
title: Any SSH box, one click away
|
||||||
|
body: Pick a host from your ssh config or your tailnet and click once — the
|
||||||
|
nook-hook installs itself and its agents appear beside your local ones,
|
||||||
|
indistinguishable.
|
||||||
|
|
||||||
|
bullets:
|
||||||
|
- One click: connect, push the hook, configure agents, check it can reach this Mac
|
||||||
|
- Hosts mirror this Mac's installed integrations automatically
|
||||||
|
- Remote sessions are ordinary rows; the agent chip names the machine
|
||||||
|
```
|
||||||
|
|
||||||
|
The section's demo animated a `gpu-rig` host moving from "Not set up" through
|
||||||
|
`connecting` → `pushing the hook binary` → `configuring agents` → `checking the
|
||||||
|
host can reach this Mac` → `Live`, then showed a remote session row.
|
||||||
|
|
||||||
|
### 3. Approvals bullet
|
||||||
|
|
||||||
|
Source: `src/components/nook/FeatureBreakdowns.tsx` (removed 2026-10-29).
|
||||||
|
|
||||||
|
```
|
||||||
|
- Works across every hooked agent, local or remote
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Feature chip
|
||||||
|
|
||||||
|
Source: `src/components/nook/IslandExtras.tsx` (removed 2026-10-29).
|
||||||
|
|
||||||
|
```
|
||||||
|
One-click SSH setup
|
||||||
|
```
|
||||||
|
|
||||||
|
## What replaced it
|
||||||
|
|
||||||
|
Nothing. The Remote tile and section were removed rather than replaced; the
|
||||||
|
existing Approvals cell already carries the verified local claim:
|
||||||
|
|
||||||
|
> Approve an agent's tool call from your Mac's notch, and the agent carries on.
|
||||||
|
|
||||||
|
## Restore checklist
|
||||||
|
|
||||||
|
1. FRE-320/FRE-205 have shipped the one-click install and removed the
|
||||||
|
`~/.ssh/config` / `ssh-copy-id` friction described in FRE-320.
|
||||||
|
2. The CTO has verified the shipping flow and the evidence is on FRE-320.
|
||||||
|
3. A recording exists showing a remote agent joining the island.
|
||||||
|
4. CMO restores the copy above, then owns the launch plan for it — with an
|
||||||
|
owner and date per item — before anything goes back on the site.
|
||||||
28
marketing/drafts/remote-ssh/rationale.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Why the remote claim is parked
|
||||||
|
|
||||||
|
The site was selling a setup path a buyer does not get.
|
||||||
|
|
||||||
|
FRE-320 measured today's actual path (2026-10-29): the one-click flow is
|
||||||
|
app-UI-only, needs a hand-edited `~/.ssh/config` for any host not already in ssh
|
||||||
|
config or on the tailnet, needs a one-time `ssh-copy-id` because the app runs ssh
|
||||||
|
with `BatchMode=yes`, and has no manual fallback for minting the per-host
|
||||||
|
credential. FRE-315 and FRE-322 §3 correction 4 marked the remote claim
|
||||||
|
unverified and demo-gated. The CTO named it the largest launch risk because it
|
||||||
|
was already in front of buyers.
|
||||||
|
|
||||||
|
So the landing page now says only what the product can back today: local agents,
|
||||||
|
the card, and the notch. The remote story is not deleted — it is parked here with
|
||||||
|
the FRE-320/FRE-205 work so it can return the day the one-click install ships and
|
||||||
|
is recorded.
|
||||||
|
|
||||||
|
## What has to be true before it returns
|
||||||
|
|
||||||
|
| Requirement | Owner | Date |
|
||||||
|
|---|---|---|
|
||||||
|
| One-click install ships without hand-edited `~/.ssh/config` or `ssh-copy-id` | FRE-320/FRE-205 engineering | When FRE-320/FRE-205 ships |
|
||||||
|
| CTO verifies the shipping flow and records the evidence on FRE-320 | CTO | Same day, before any copy moves |
|
||||||
|
| A recording shows a remote agent joining the island | Sponsor/CMO | Before restore |
|
||||||
|
| Restore copy and rebuild the launch plan with an owner + date per item | CMO | Day the three rows above are true |
|
||||||
|
|
||||||
|
Nothing in this folder is publishable. `marketing/approved/` remains the only
|
||||||
|
place a human publishes from.
|
||||||
15
marketing/listening.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Listening — buying-intent threads
|
||||||
|
|
||||||
|
Agent-found threads where someone is describing a problem The Nook solves, with a
|
||||||
|
drafted helpful-first reply. Replies are drafted here, reviewed, and posted **by a
|
||||||
|
human** with a single disclosed mention — helpful first, product second.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Real threads only — no invented scenarios.
|
||||||
|
- The drafted reply must stand alone as a helpful answer if the mention is removed.
|
||||||
|
- One disclosed mention per reply, maximum.
|
||||||
|
- Never post from this file; replies move through `approved/`.
|
||||||
|
|
||||||
|
| Date | Source | Thread | Problem signal | Drafted reply |
|
||||||
|
|---|---|---|---|---|
|
||||||
4
marketing/metrics/weekly.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Weekly funnel snapshot
|
||||||
|
|
||||||
|
Generated weekly from the analytics DB + `trials`/`licenses`/`activations`.
|
||||||
|
Format: the funnel numbers, what moved, and the one thing worth acting on.
|
||||||
1
marketing/pending/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# pending
|
||||||
106
marketing/positioning.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
Primary source: `~/Code/the-nook/docs/marketing-strategy.md`.
|
||||||
|
|
||||||
|
## The sentence
|
||||||
|
|
||||||
|
> **Your Mac and your agents, under control.** The Nook puts your whole coding-agent
|
||||||
|
> fleet in the notch — thirteen agents — so you see what is running,
|
||||||
|
> unblock it in one tap, and keep the machine from overheating 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 bundling reframe
|
||||||
|
|
||||||
|
Three audiences (agent herders, hardware tinkerers, glanceable-widget users) in one
|
||||||
|
$10 app reads like a grab bag. Reframe as consolidation, which is the honest story:
|
||||||
|
|
||||||
|
> One $10 app instead of a notch toy, a fan utility, a battery limiter, a menu-bar
|
||||||
|
> stats strip, and a notification hack. Same one-time purchase, no subscription.
|
||||||
|
|
||||||
|
Price rises to $15 at 1.0 — dated and honest, not a permanent beta.
|
||||||
|
|
||||||
|
## 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 requires a test to exist
|
||||||
|
|
||||||
|
Copy that says "13 agents" or "no data leaves the device" must have a test backing it.
|
||||||
|
If a claim cannot be pointed at code or a test, it does not go in copy.
|
||||||
|
|
||||||
|
### 3. Honest privacy scoping
|
||||||
|
|
||||||
|
Telemetry and the trial email **do** leave the device. The defensible claim is:
|
||||||
|
|
||||||
|
> **No prompt or code content leaves the device.**
|
||||||
|
|
||||||
|
Never "no data leaves the device".
|
||||||
|
|
||||||
|
### 4. Sell what works today
|
||||||
|
|
||||||
|
Phone approvals are deferred until a shipped iOS app exists — no "(coming soon)"
|
||||||
|
parentheticals. The launch story is the approval card: an agent blocked on a
|
||||||
|
permission prompt, unblocked from the Mac's notch, and work resumes.
|
||||||
|
|
||||||
|
Remote SSH is **held**, not approved. The one-click install has not shipped and
|
||||||
|
has not been recorded (FRE-320, FRE-205), and the current path needs hand-edited
|
||||||
|
`~/.ssh/config` and a one-time `ssh-copy-id` (FRE-320). Its copy is parked in
|
||||||
|
`marketing/drafts/remote-ssh/` until the CTO verifies the shipping flow and a
|
||||||
|
demo exists.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## Approved claims (each backed by code)
|
||||||
|
|
||||||
|
| Claim | Backing |
|
||||||
|
|---|---|
|
||||||
|
| Thirteen hooked agents, one glance | `AgentSpec.all` — claude, codex, opencode, pi, omp, muse, hermes, openclaw, zai, gemini, cline, qwen, deepseek |
|
||||||
|
| Permission gate answered from a card | awaitPolicyTable + bridge directive path (`hook/normalize.go`, `serialize.go`) |
|
||||||
|
| Fan / thermal / charge control | `helper/` (privileged helper, SMC, no kext) |
|
||||||
|
| One-time purchase, no subscription | $10 → $15 at 1.0; Stripe checkout |
|
||||||
|
| macOS 14+ | 27 source files use the `@Observable` framework |
|
||||||
|
| No prompt or code content leaves the device | envelope carries session/event/terminal metadata only (see `docs/agent-interface.md`) |
|
||||||
|
|
||||||
|
## Held claims (not publishable)
|
||||||
|
|
||||||
|
| Claim | Why it is held | What has to be true first |
|
||||||
|
|---|---|---|
|
||||||
|
| Remote agents over SSH, one click to install | The one-click path is app-UI-only and needs hand-edited `~/.ssh/config` plus a one-time `ssh-copy-id`; no demo exists (FRE-320). FRE-315 and FRE-322 hold it unverified and demo-gated. | FRE-320/FRE-205 ship a genuinely one-click install, the CTO verifies it, and a recording shows a remote agent joining the island. Copy parked in `marketing/drafts/remote-ssh/`. |
|
||||||
|
|
||||||
|
## Voice
|
||||||
|
|
||||||
|
- Short, concrete, no hype adjectives. Numbers over adjectives ("thirteen agents",
|
||||||
|
"one tap", "$10 once").
|
||||||
|
- The notch/island 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". Unverifiable superlatives generally.
|
||||||
1
marketing/posted/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# posted
|
||||||
24
package.json
@@ -1,36 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "example-with-trpc",
|
"name": "freno.(dev/me)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vinxi dev",
|
"dev": "vinxi dev",
|
||||||
"dev-flush": "vinxi dev --env-file=.env",
|
"dev-flush": "vinxi dev --env-file=.env",
|
||||||
"build": "vinxi build",
|
"build": "vinxi build",
|
||||||
"start": "vinxi start",
|
"start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vinxi start",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:security": "bun test src/server/security/",
|
"test:security": "bun test src/server/security/",
|
||||||
"test:watch": "bun test --watch",
|
"test:watch": "bun test --watch",
|
||||||
"test:coverage": "bun test --coverage",
|
"test:coverage": "bun test --coverage",
|
||||||
"perf": "bun run scripts/perf-test.ts",
|
"perf": "bun run scripts/perf-test.ts",
|
||||||
"perf:compare": "bun run scripts/perf-compare.ts"
|
"perf:compare": "bun run scripts/perf-compare.ts",
|
||||||
|
"nook:grant": "NODE_ENV=production bun --env-file=.env scripts/grant-nook-license.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.953.0",
|
"@aws-sdk/client-s3": "^3.953.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
"@aws-sdk/s3-request-presigner": "^3.953.0",
|
||||||
"@libsql/client": "^0.15.15",
|
"@libsql/client": "^0.15.15",
|
||||||
"@motionone/solid": "^10.16.4",
|
"@sentry/solidstart": "^10.67.0",
|
||||||
"@solidjs/meta": "^0.29.4",
|
"@solidjs/meta": "^0.29.4",
|
||||||
"@solidjs/router": "^0.15.0",
|
"@solidjs/router": "^0.15.0",
|
||||||
"@solidjs/start": "^1.1.0",
|
"@solidjs/start": "^1.1.0",
|
||||||
"@tailwindcss/vite": "^4.0.7",
|
"@tailwindcss/vite": "^4.0.7",
|
||||||
"@tiptap/core": "^3.14.0",
|
"@tiptap/core": "^3.14.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^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": "^3.14.0",
|
||||||
"@tiptap/extension-details-content": "^2.26.2",
|
"@tiptap/extension-details-content": "^2.26.2",
|
||||||
"@tiptap/extension-details-summary": "^2.26.2",
|
"@tiptap/extension-details-summary": "^2.26.2",
|
||||||
"@tiptap/extension-image": "^3.14.0",
|
"@tiptap/extension-image": "^3.14.0",
|
||||||
"@tiptap/extension-link": "^3.14.0",
|
"@tiptap/extension-link": "^3.14.0",
|
||||||
"@tiptap/extension-list-item": "^3.14.0",
|
|
||||||
"@tiptap/extension-subscript": "^3.14.0",
|
"@tiptap/extension-subscript": "^3.14.0",
|
||||||
"@tiptap/extension-superscript": "^3.14.0",
|
"@tiptap/extension-superscript": "^3.14.0",
|
||||||
"@tiptap/extension-table": "^3.14.0",
|
"@tiptap/extension-table": "^3.14.0",
|
||||||
@@ -40,13 +39,11 @@
|
|||||||
"@tiptap/extension-task-item": "^3.14.0",
|
"@tiptap/extension-task-item": "^3.14.0",
|
||||||
"@tiptap/extension-task-list": "^3.14.0",
|
"@tiptap/extension-task-list": "^3.14.0",
|
||||||
"@tiptap/extension-text-align": "^3.14.0",
|
"@tiptap/extension-text-align": "^3.14.0",
|
||||||
"@tiptap/extension-text-style": "^3.14.0",
|
|
||||||
"@tiptap/pm": "^3.14.0",
|
"@tiptap/pm": "^3.14.0",
|
||||||
"@tiptap/starter-kit": "^3.14.0",
|
"@tiptap/starter-kit": "^3.14.0",
|
||||||
"@trpc/client": "^10.45.2",
|
"@trpc/client": "^10.45.2",
|
||||||
"@trpc/server": "^10.45.2",
|
"@trpc/server": "^10.45.2",
|
||||||
"@tursodatabase/api": "^1.9.2",
|
"@tursodatabase/api": "^1.9.2",
|
||||||
"@typeschema/valibot": "^0.13.4",
|
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"es-toolkit": "^1.43.0",
|
"es-toolkit": "^1.43.0",
|
||||||
"fast-diff": "^1.3.0",
|
"fast-diff": "^1.3.0",
|
||||||
@@ -55,7 +52,6 @@
|
|||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
"motion": "^12.23.26",
|
"motion": "^12.23.26",
|
||||||
"redis": "^5.10.0",
|
|
||||||
"solid-js": "^1.9.5",
|
"solid-js": "^1.9.5",
|
||||||
"solid-tiptap": "^0.8.0",
|
"solid-tiptap": "^0.8.0",
|
||||||
"ua-parser-js": "^2.0.7",
|
"ua-parser-js": "^2.0.7",
|
||||||
@@ -67,17 +63,11 @@
|
|||||||
"node": "24.x"
|
"node": "24.x"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.57.0",
|
"@sentry/vite-plugin": "^5.4.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/fast-diff": "^1.2.2",
|
|
||||||
"chrome-launcher": "^1.2.1",
|
|
||||||
"lighthouse": "^13.0.1",
|
|
||||||
"playwright": "^1.57.0",
|
"playwright": "^1.57.0",
|
||||||
"prettier": "^3.7.4",
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/Gaze Exports/Gaze-iOS-Dark-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/Gaze Exports/Gaze-iOS-Default-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/InputHalo Exports/InputHalo-iOS-Dark-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/InputHalo Exports/InputHalo-iOS-Default-1024x1024@1x.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/Nessa Exports/01-home-tab.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/Nessa Exports/04-plans-tab-strength.png
Normal file
|
After Width: | Height: | Size: 379 KiB |
BIN
public/Nessa Exports/09-clubs-list.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
public/Nessa Exports/29-apple-health.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
public/Nessa Exports/38-workout-summary.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
public/Nessa Exports/40-segment-list.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/Nessa Exports/Nessa-iOS-Dark-1024x1024.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/Nessa Exports/Nessa-iOS-Default-1024x1024.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/gaze/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/gaze/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/gaze/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 8.7 KiB |
17
public/gaze/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 5.9 MiB |
21
public/gaze/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
BIN
public/gaze/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/gaze/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 158 KiB |
BIN
public/inputhalo/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/inputhalo/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/inputhalo/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 6.5 KiB |
17
public/inputhalo/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 5.0 MiB |
21
public/inputhalo/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
BIN
public/inputhalo/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/inputhalo/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 109 KiB |
12
public/instrument.server.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import * as Sentry from "@sentry/solidstart";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: "https://a7c36d42c2a023ed29dd5db76c079566@o4506630160187392.ingest.us.sentry.io/4511784457666560",
|
||||||
|
|
||||||
|
dataCollection: {
|
||||||
|
// To disable sending user data and HTTP bodies, uncomment the lines below. For more info visit:
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/solidstart/configuration/options/#dataCollection
|
||||||
|
// userInfo: false,
|
||||||
|
// httpBodies: [],
|
||||||
|
}
|
||||||
|
});
|
||||||
BIN
public/lineage/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/lineage/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/lineage/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 15 KiB |
3
public/lineage/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 860 KiB |
21
public/lineage/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
BIN
public/lineage/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/lineage/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/look-away.png
Normal file
|
After Width: | Height: | Size: 585 KiB |
BIN
public/nessa/favicon/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/nessa/favicon/favicon-96x96.png
Executable file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/nessa/favicon/favicon.ico
Executable file
|
After Width: | Height: | Size: 1.9 KiB |
17
public/nessa/favicon/favicon.svg
Executable file
|
After Width: | Height: | Size: 64 KiB |
21
public/nessa/favicon/site.webmanifest
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
BIN
public/nessa/favicon/web-app-manifest-192x192.png
Executable file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/nessa/favicon/web-app-manifest-512x512.png
Executable file
|
After Width: | Height: | Size: 8.3 KiB |
4
public/nook/brands/claude.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Official Claude mark — Anthropic favicon, https://claude.ai/favicon.svg -->
|
||||||
|
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
4
public/nook/brands/codex.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<!-- Codex mark — official OpenAI Codex X (canonical path used by Cloudflare docs / lobe-icons / shadcn logos, all sourced from the OpenAI brand), white colorway for dark surfaces -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#FFFFFF" fill-rule="evenodd">
|
||||||
|
<path d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/nook/cam-recording.mp4
Normal file
BIN
public/nook/demo-expansion.mp4
Normal file
BIN
public/nook/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/nook/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
public/nook/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
21
public/nook/favicon/site.webmanifest
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
BIN
public/nook/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/nook/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
public/nook/icon.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
public/nook/og.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
@@ -1,9 +1,21 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /blog
|
Allow: /blog
|
||||||
Allow: /projects
|
|
||||||
Disallow: /login
|
# Private / dynamic paths — not for indexing.
|
||||||
Disallow: /debug/
|
# Keeps crawlers off auth, admin, and API pages so they don't burn
|
||||||
|
# uncached SSR function invocations.
|
||||||
|
Disallow: /account
|
||||||
|
Disallow: /analytics
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /blog/create
|
||||||
|
Disallow: /blog/edit
|
||||||
|
Disallow: /checkout
|
||||||
Disallow: /databaseMGMT
|
Disallow: /databaseMGMT
|
||||||
|
Disallow: /debug/
|
||||||
|
Disallow: /error-test
|
||||||
|
Disallow: /login
|
||||||
|
Disallow: /success
|
||||||
|
Disallow: /test
|
||||||
|
|
||||||
Sitemap: https://www.freno.me/sitemap.xml
|
Sitemap: https://www.freno.me/sitemap.xml
|
||||||
|
|||||||
21
scripts/backfill-downloads.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ConnectionFactory } from "~/server/db-connections";
|
||||||
|
import { backfillDownloads } from "~/server/backfill-downloads";
|
||||||
|
|
||||||
|
// One-off: derive canonical /api/downloads/<asset> rows from download history
|
||||||
|
// recorded before `trackDownload` existed. Idempotent — re-running adds
|
||||||
|
// nothing, because derived rows carry a `backfill-<source id>` primary key.
|
||||||
|
//
|
||||||
|
// bun --env-file=.env scripts/backfill-downloads.ts
|
||||||
|
|
||||||
|
const result = await backfillDownloads(ConnectionFactory());
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.log("Nothing to backfill: no legacy download rows left to derive.");
|
||||||
|
} else {
|
||||||
|
for (const { asset, added } of result) {
|
||||||
|
console.log(`${asset}: +${added}`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Total: +${result.reduce((sum, row) => sum + row.added, 0)} derived download rows.`
|
||||||
|
);
|
||||||
|
}
|
||||||
45
scripts/generate-license-keys.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* 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.");
|
||||||
28
scripts/grant-nook-license.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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,7 +96,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
"───────────────────────────────────────────────────────────────────\n"
|
"───────────────────────────────────────────────────────────────────\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compare each page
|
|
||||||
for (const baseResult of baseline.results) {
|
for (const baseResult of baseline.results) {
|
||||||
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
const optResult = optimized.results.find((r) => r.page === baseResult.page);
|
||||||
if (!optResult) continue;
|
if (!optResult) continue;
|
||||||
@@ -107,7 +106,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
console.log(`\n📄 ${baseResult.page}`);
|
console.log(`\n📄 ${baseResult.page}`);
|
||||||
console.log("─".repeat(70));
|
console.log("─".repeat(70));
|
||||||
|
|
||||||
// Core Web Vitals
|
|
||||||
console.log("\n Core Web Vitals:");
|
console.log("\n Core Web Vitals:");
|
||||||
|
|
||||||
const fcpDiff = opt.fcp - base.fcp;
|
const fcpDiff = opt.fcp - base.fcp;
|
||||||
@@ -121,7 +119,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
` CLS: ${base.cls.toFixed(3)} → ${opt.cls.toFixed(3)} (${formatDiff(clsDiff * 1000, "ms")})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Loading Metrics
|
|
||||||
console.log("\n Loading Metrics:");
|
console.log("\n Loading Metrics:");
|
||||||
|
|
||||||
const ttfbDiff = opt.ttfb - base.ttfb;
|
const ttfbDiff = opt.ttfb - base.ttfb;
|
||||||
@@ -148,7 +145,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
` Load: ${formatTime(base.loadComplete)} → ${formatTime(opt.loadComplete)} (${formatDiff(loadDiff, "ms")}, ${loadPercent.toFixed(1)}%)${getImpact(loadPercent)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resource Loading
|
|
||||||
console.log("\n Resources:");
|
console.log("\n Resources:");
|
||||||
|
|
||||||
const reqDiff = opt.totalRequests - base.totalRequests;
|
const reqDiff = opt.totalRequests - base.totalRequests;
|
||||||
@@ -185,7 +181,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overall Summary
|
|
||||||
console.log(
|
console.log(
|
||||||
"\n\n═══════════════════════════════════════════════════════════════════"
|
"\n\n═══════════════════════════════════════════════════════════════════"
|
||||||
);
|
);
|
||||||
@@ -338,7 +333,6 @@ function compareResults(baseline: TestOutput, optimized: TestOutput) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Specific findings
|
|
||||||
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
const reqPercent = calculatePercentChange(baseAvg.requests, optAvg.requests);
|
||||||
if (reqPercent < -5) {
|
if (reqPercent < -5) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
|||||||
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
||||||
const WARMUP_RUNS = 1;
|
const WARMUP_RUNS = 1;
|
||||||
|
|
||||||
// Pages to test
|
|
||||||
const TEST_PAGES: PageTestConfig[] = [
|
const TEST_PAGES: PageTestConfig[] = [
|
||||||
{ name: "Home", path: "/" },
|
{ name: "Home", path: "/" },
|
||||||
{ name: "Blog Index", path: "/blog" },
|
{ name: "Blog Index", path: "/blog" },
|
||||||
@@ -78,7 +77,6 @@ const TEST_PAGES: PageTestConfig[] = [
|
|||||||
{ name: "404", path: "/404" }
|
{ name: "404", path: "/404" }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add additional blog post path if provided
|
|
||||||
if (process.env.TEST_BLOG_POST) {
|
if (process.env.TEST_BLOG_POST) {
|
||||||
TEST_PAGES.push({
|
TEST_PAGES.push({
|
||||||
name: "Custom Blog Post",
|
name: "Custom Blog Post",
|
||||||
@@ -102,7 +100,6 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
interactions: [] as number[]
|
interactions: [] as number[]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Observe LCP
|
|
||||||
if ("PerformanceObserver" in window) {
|
if ("PerformanceObserver" in window) {
|
||||||
try {
|
try {
|
||||||
const lcpObserver = new PerformanceObserver((entryList) => {
|
const lcpObserver = new PerformanceObserver((entryList) => {
|
||||||
@@ -118,10 +115,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
buffered: true
|
buffered: true
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// LCP not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe CLS
|
|
||||||
try {
|
try {
|
||||||
const clsObserver = new PerformanceObserver((entryList) => {
|
const clsObserver = new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
@@ -138,10 +133,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
clsObserver.observe({ type: "layout-shift", buffered: true });
|
clsObserver.observe({ type: "layout-shift", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// CLS not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe FID (first input)
|
|
||||||
try {
|
try {
|
||||||
const fidObserver = new PerformanceObserver((entryList) => {
|
const fidObserver = new PerformanceObserver((entryList) => {
|
||||||
const firstInput = entryList.getEntries()[0] as any;
|
const firstInput = entryList.getEntries()[0] as any;
|
||||||
@@ -154,10 +147,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
fidObserver.observe({ type: "first-input", buffered: true });
|
fidObserver.observe({ type: "first-input", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// FID not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe long tasks
|
|
||||||
try {
|
try {
|
||||||
const longTaskObserver = new PerformanceObserver((entryList) => {
|
const longTaskObserver = new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
@@ -166,10 +157,8 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
longTaskObserver.observe({ type: "longtask", buffered: true });
|
longTaskObserver.observe({ type: "longtask", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Long tasks not supported
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe INP (event timing for interactions)
|
|
||||||
try {
|
try {
|
||||||
const inpObserver = new PerformanceObserver((entryList) => {
|
const inpObserver = new PerformanceObserver((entryList) => {
|
||||||
for (const entry of entryList.getEntries()) {
|
for (const entry of entryList.getEntries()) {
|
||||||
@@ -194,7 +183,6 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
});
|
});
|
||||||
inpObserver.observe({ type: "event", buffered: true });
|
inpObserver.observe({ type: "event", buffered: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Event timing not supported
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -203,18 +191,14 @@ async function setupPerformanceObservers(page: Page) {
|
|||||||
async function collectPerformanceMetrics(
|
async function collectPerformanceMetrics(
|
||||||
page: Page
|
page: Page
|
||||||
): Promise<PerformanceMetrics> {
|
): Promise<PerformanceMetrics> {
|
||||||
// Wait for page to be loaded
|
|
||||||
await page.waitForLoadState("load");
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
// Wait a bit longer for LCP to settle (it can change as content loads)
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Additional wait for any remaining network activity
|
|
||||||
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
||||||
// Ignore timeout - networkidle may never happen for some pages
|
// Ignore timeout - networkidle may never happen for some pages
|
||||||
});
|
});
|
||||||
|
|
||||||
// Collect comprehensive performance metrics
|
|
||||||
const metrics = await page.evaluate(() => {
|
const metrics = await page.evaluate(() => {
|
||||||
const perf = performance.getEntriesByType(
|
const perf = performance.getEntriesByType(
|
||||||
"navigation"
|
"navigation"
|
||||||
@@ -222,7 +206,6 @@ async function collectPerformanceMetrics(
|
|||||||
const paint = performance.getEntriesByType("paint");
|
const paint = performance.getEntriesByType("paint");
|
||||||
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
||||||
|
|
||||||
// Get metrics from our observers
|
|
||||||
const observedMetrics = (window as any).__perfMetrics || {
|
const observedMetrics = (window as any).__perfMetrics || {
|
||||||
lcp: 0,
|
lcp: 0,
|
||||||
cls: 0,
|
cls: 0,
|
||||||
@@ -232,7 +215,6 @@ async function collectPerformanceMetrics(
|
|||||||
interactions: []
|
interactions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback to direct API if observers didn't capture anything
|
|
||||||
let lcp = observedMetrics.lcp;
|
let lcp = observedMetrics.lcp;
|
||||||
let cls = observedMetrics.cls;
|
let cls = observedMetrics.cls;
|
||||||
let fid = observedMetrics.fid;
|
let fid = observedMetrics.fid;
|
||||||
@@ -258,7 +240,6 @@ async function collectPerformanceMetrics(
|
|||||||
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate INP from event timing entries if not already captured
|
|
||||||
if (inp === 0) {
|
if (inp === 0) {
|
||||||
const eventEntries = performance.getEntriesByType("event") as any[];
|
const eventEntries = performance.getEntriesByType("event") as any[];
|
||||||
const interactionLatencies = eventEntries
|
const interactionLatencies = eventEntries
|
||||||
@@ -275,7 +256,6 @@ async function collectPerformanceMetrics(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get resource timing
|
|
||||||
const resources = performance.getEntriesByType(
|
const resources = performance.getEntriesByType(
|
||||||
"resource"
|
"resource"
|
||||||
) as PerformanceResourceTiming[];
|
) as PerformanceResourceTiming[];
|
||||||
@@ -318,7 +298,6 @@ async function collectPerformanceMetrics(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate long task duration
|
|
||||||
let taskDuration = 0;
|
let taskDuration = 0;
|
||||||
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
||||||
taskDuration = observedMetrics.longTasks.reduce(
|
taskDuration = observedMetrics.longTasks.reduce(
|
||||||
@@ -327,7 +306,6 @@ async function collectPerformanceMetrics(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get more granular performance entries
|
|
||||||
let jsExecutionTime = 0;
|
let jsExecutionTime = 0;
|
||||||
let layoutDuration = 0;
|
let layoutDuration = 0;
|
||||||
let paintDuration = 0;
|
let paintDuration = 0;
|
||||||
@@ -339,7 +317,6 @@ async function collectPerformanceMetrics(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for script evaluation entries
|
|
||||||
const entries = performance.getEntries();
|
const entries = performance.getEntries();
|
||||||
entries.forEach((entry: any) => {
|
entries.forEach((entry: any) => {
|
||||||
if (entry.entryType === "measure") {
|
if (entry.entryType === "measure") {
|
||||||
@@ -399,7 +376,6 @@ async function testPagePerformance(
|
|||||||
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Warmup runs (not counted)
|
|
||||||
for (let i = 0; i < WARMUP_RUNS; i++) {
|
for (let i = 0; i < WARMUP_RUNS; i++) {
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -410,20 +386,16 @@ async function testPagePerformance(
|
|||||||
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measured runs
|
|
||||||
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
||||||
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
||||||
|
|
||||||
// Create new context for each run to ensure clean state
|
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
viewport: { width: 1920, height: 1080 }
|
viewport: { width: 1920, height: 1080 }
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
// Setup performance observers before navigation
|
|
||||||
await setupPerformanceObservers(page);
|
await setupPerformanceObservers(page);
|
||||||
|
|
||||||
// Navigate and collect metrics
|
|
||||||
await page.goto(url, { waitUntil: "load" });
|
await page.goto(url, { waitUntil: "load" });
|
||||||
const metrics = await collectPerformanceMetrics(page);
|
const metrics = await collectPerformanceMetrics(page);
|
||||||
|
|
||||||
@@ -436,7 +408,6 @@ async function testPagePerformance(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate statistics
|
|
||||||
const average = calculateAverage(runs);
|
const average = calculateAverage(runs);
|
||||||
const median = calculateMedian(runs);
|
const median = calculateMedian(runs);
|
||||||
const p95 = calculatePercentile(runs, 95);
|
const p95 = calculatePercentile(runs, 95);
|
||||||
@@ -648,7 +619,6 @@ function printResults(results: TestResult[]) {
|
|||||||
"═══════════════════════════════════════════════════════════════════\n"
|
"═══════════════════════════════════════════════════════════════════\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Overall averages
|
|
||||||
const overallAverage = {
|
const overallAverage = {
|
||||||
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
||||||
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
||||||
@@ -696,7 +666,6 @@ function printResults(results: TestResult[]) {
|
|||||||
|
|
||||||
console.log("\n Optimization Opportunities:");
|
console.log("\n Optimization Opportunities:");
|
||||||
|
|
||||||
// Find pages with highest JS bytes
|
|
||||||
const highestJS = [...results].sort(
|
const highestJS = [...results].sort(
|
||||||
(a, b) => b.median.jsBytes - a.median.jsBytes
|
(a, b) => b.median.jsBytes - a.median.jsBytes
|
||||||
)[0];
|
)[0];
|
||||||
@@ -707,7 +676,6 @@ function printResults(results: TestResult[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pages with slow LCP
|
|
||||||
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
||||||
if (slowLCP.length > 0) {
|
if (slowLCP.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -715,7 +683,6 @@ function printResults(results: TestResult[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pages with high CLS
|
|
||||||
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
||||||
if (highCLS.length > 0) {
|
if (highCLS.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -723,7 +690,6 @@ function printResults(results: TestResult[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find pages with high INP
|
|
||||||
const highINP = results.filter((r) => r.median.inp > 200);
|
const highINP = results.filter((r) => r.median.inp > 200);
|
||||||
if (highINP.length > 0) {
|
if (highINP.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -740,7 +706,6 @@ async function main() {
|
|||||||
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
||||||
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
||||||
|
|
||||||
// Check if server is running
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(BASE_URL);
|
const response = await fetch(BASE_URL);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -770,10 +735,8 @@ async function main() {
|
|||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
||||||
// Print results
|
|
||||||
printResults(results);
|
printResults(results);
|
||||||
|
|
||||||
// Save results to JSON file
|
|
||||||
const timestamp = new Date()
|
const timestamp = new Date()
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.replace(/[:.]/g, "-")
|
.replace(/[:.]/g, "-")
|
||||||
|
|||||||
38
scripts/upload-appcast.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// One-off: upload the backfilled appcast to the download bucket.
|
||||||
|
// Usage: cd ~/Code/freno-dev && bun --env-file=.env /tmp/upload-appcast.ts <file>
|
||||||
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const [, , file] = process.argv;
|
||||||
|
if (!file) {
|
||||||
|
console.error("usage: bun --env-file=.env upload-appcast.ts <appcast.xml>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = readFileSync(file);
|
||||||
|
const xml = body.toString("utf8");
|
||||||
|
// Guard: refuse to upload without all three release descriptions present.
|
||||||
|
for (const v of ["0.6.0", "0.6.2", "0.6.3"]) {
|
||||||
|
if (!xml.includes(`sparkle:shortVersionString>${v}<`)) {
|
||||||
|
console.error(`refusing: item for ${v} missing`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
region: process.env.AWS_REGION,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.MY_AWS_ACCESS_KEY!,
|
||||||
|
secretAccessKey: process.env.MY_AWS_SECRET_KEY!
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: process.env.VITE_DOWNLOAD_BUCKET_STRING,
|
||||||
|
Key: "api/TheNook/appcast.xml",
|
||||||
|
Body: body,
|
||||||
|
ContentType: "application/xml; charset=utf-8"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
console.log("uploaded", file, "->", "api/TheNook/appcast.xml");
|
||||||
259
scripts/verify-sparkle-dual-host.sh
Executable file
@@ -0,0 +1,259 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sparkle Appcast Dual-Host Verification Script
|
||||||
|
#
|
||||||
|
# Verifies that Sparkle appcast and DMG endpoints work from both
|
||||||
|
# freno.me (legacy) and subdomain hosts (new)
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/verify-sparkle-dual-host.sh [product]
|
||||||
|
# product: Gaze | InputHalo | all (default: all)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BASE_URL="https://freno.me"
|
||||||
|
SUBDOMAINS=(
|
||||||
|
"gaze:freno.me"
|
||||||
|
"inputhalo:freno.me"
|
||||||
|
"nessa:freno.me"
|
||||||
|
"lineage:freno.me"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Latest versions from appcast (update as needed)
|
||||||
|
LATEST_GAZE_VERSION="0.7.8"
|
||||||
|
LATEST_INPUTHALO_VERSION="0.5.2"
|
||||||
|
|
||||||
|
# Counters
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
WARN=0
|
||||||
|
|
||||||
|
# Helper functions
|
||||||
|
log_pass() {
|
||||||
|
echo -e "${GREEN}✓ PASS${NC}: $1"
|
||||||
|
((PASS++))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_fail() {
|
||||||
|
echo -e "${RED}✗ FAIL${NC}: $1"
|
||||||
|
((FAIL++))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}! WARN${NC}: $1"
|
||||||
|
((WARN++))
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}ℹ INFO${NC}: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check appcast endpoint
|
||||||
|
check_appcast() {
|
||||||
|
local product=$1
|
||||||
|
local host=$2
|
||||||
|
local url="https://${host}/api/${product}/appcast.xml"
|
||||||
|
|
||||||
|
log_info "Checking appcast for ${product} on ${host}..."
|
||||||
|
|
||||||
|
# Test 1: HTTP status
|
||||||
|
local status=$(curl -sI -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
log_pass "${product} appcast on ${host}: HTTP 200"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: HTTP ${status} (expected 200)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 2: Content-Type header
|
||||||
|
local content_type=$(curl -sI "$url" 2>/dev/null | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [[ "$content_type" == *"application/xml"* ]]; then
|
||||||
|
log_pass "${product} appcast on ${host}: Correct Content-Type"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Wrong Content-Type: ${content_type}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 3: Cache-Control header
|
||||||
|
local cache_control=$(curl -sI "$url" 2>/dev/null | grep -i "cache-control" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [[ "$cache_control" == *"max-age=300"* ]]; then
|
||||||
|
log_pass "${product} appcast on ${host}: Correct Cache-Control"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Wrong Cache-Control: ${cache_control}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 4: CORS header
|
||||||
|
local cors=$(curl -sI "$url" 2>/dev/null | grep -i "access-control-allow-origin" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [ "$cors" = "*" ]; then
|
||||||
|
log_pass "${product} appcast on ${host}: CORS header present"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Missing CORS header"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 5: Valid XML
|
||||||
|
local xml=$(curl -s "$url" 2>/dev/null)
|
||||||
|
if echo "$xml" | xmllint --noout - 2>/dev/null; then
|
||||||
|
log_pass "${product} appcast on ${host}: Valid XML"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Invalid XML"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test 6: Check for absolute enclosure URLs
|
||||||
|
if echo "$xml" | grep -q 'enclosure url="https://freno\.me/api/downloads/'; then
|
||||||
|
log_pass "${product} appcast on ${host}: Uses absolute freno.me enclosure URLs"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast on ${host}: Missing absolute enclosure URLs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare appcast between hosts
|
||||||
|
compare_appcast() {
|
||||||
|
local product=$1
|
||||||
|
local base_host="freno.me"
|
||||||
|
local subdomain_host=$2
|
||||||
|
|
||||||
|
log_info "Comparing ${product} appcast between ${base_host} and ${subdomain_host}..."
|
||||||
|
|
||||||
|
local base_xml=$(curl -s "https://${base_host}/api/${product}/appcast.xml" 2>/dev/null)
|
||||||
|
local subdomain_xml=$(curl -s "https://${subdomain_host}/api/${product}/appcast.xml" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$base_xml" = "$subdomain_xml" ]; then
|
||||||
|
log_pass "${product} appcast: Byte-identical between ${base_host} and ${subdomain_host}"
|
||||||
|
else
|
||||||
|
log_fail "${product} appcast: Content differs between ${base_host} and ${subdomain_host}"
|
||||||
|
echo "$base_xml" > /tmp/base-appcast.xml
|
||||||
|
echo "$subdomain_xml" > /tmp/subdomain-appcast.xml
|
||||||
|
echo "Differences saved to /tmp/base-appcast.xml and /tmp/subdomain-appcast.xml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check DMG download endpoint
|
||||||
|
check_dmg_download() {
|
||||||
|
local product=$1
|
||||||
|
local host=$2
|
||||||
|
local version=$3
|
||||||
|
local filename="${product}-${version}.dmg"
|
||||||
|
local url="https://${host}/api/downloads/${filename}"
|
||||||
|
|
||||||
|
log_info "Checking DMG download for ${product} on ${host} (${filename})..."
|
||||||
|
|
||||||
|
# Test HTTP status (just head request for speed)
|
||||||
|
local status=$(curl -sI -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
log_pass "${product} DMG on ${host}: HTTP 200"
|
||||||
|
else
|
||||||
|
log_fail "${product} DMG on ${host}: HTTP ${status} (expected 200)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test Content-Type
|
||||||
|
local content_type=$(curl -sI "$url" 2>/dev/null | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
|
||||||
|
if [[ "$content_type" == *"apple-diskimage"* ]] || [[ "$content_type" == *"octet-stream"* ]]; then
|
||||||
|
log_pass "${product} DMG on ${host}: Correct Content-Type"
|
||||||
|
else
|
||||||
|
log_fail "${product} DMG on ${host}: Wrong Content-Type: ${content_type}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main verification
|
||||||
|
main() {
|
||||||
|
local product=${1:-"all"}
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo "Sparkle Appcast Dual-Host Verification"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
echo "Base URL: ${BASE_URL}"
|
||||||
|
echo "Product: ${product}"
|
||||||
|
echo "Date: $(date)"
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "1. Checking Appcast Endpoints"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check Gaze appcast on all relevant hosts
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "Gaze" ]; then
|
||||||
|
check_appcast "Gaze" "freno.me"
|
||||||
|
check_appcast "Gaze" "gaze.freno.me"
|
||||||
|
compare_appcast "Gaze" "gaze.freno.me"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check InputHalo appcast on all relevant hosts
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "InputHalo" ]; then
|
||||||
|
check_appcast "InputHalo" "freno.me"
|
||||||
|
check_appcast "InputHalo" "inputhalo.freno.me"
|
||||||
|
compare_appcast "InputHalo" "inputhalo.freno.me"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "2. Checking DMG Download Endpoints"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check all subdomain hosts for DMG downloads
|
||||||
|
for subdomain_entry in "${SUBDOMAINS[@]}"; do
|
||||||
|
local subdomain_host="${subdomain_entry//:/}"
|
||||||
|
subdomain_host="${subdomain_host//:/}.freno.me"
|
||||||
|
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "Gaze" ]; then
|
||||||
|
check_dmg_download "Gaze" "$subdomain_host" "$LATEST_GAZE_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$product" = "all" ] || [ "$product" = "InputHalo" ]; then
|
||||||
|
check_dmg_download "InputHalo" "$subdomain_host" "$LATEST_INPUTHALO_VERSION"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "3. Checking vercel.json Rewrite Ordering"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check that /api/* pass-throughs come before catch-all rewrites
|
||||||
|
local api_rewrites=$(grep -n "source.*api" vercel.json | head -4 | wc -l)
|
||||||
|
local catchall_rewrites=$(grep -n "source.*\(.*)$" vercel.json | grep -v "api" | wc -l)
|
||||||
|
|
||||||
|
if [ "$api_rewrites" -eq 4 ]; then
|
||||||
|
log_pass "Found ${api_rewrites} /api/* pass-through rules"
|
||||||
|
else
|
||||||
|
log_fail "Expected 4 /api/* pass-through rules, found ${api_rewrites}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$catchall_rewrites" -eq 4 ]; then
|
||||||
|
log_pass "Found ${catchall_rewrites} catch-all subdomain rewrite rules"
|
||||||
|
else
|
||||||
|
log_fail "Expected 4 catch-all subdomain rewrite rules, found ${catchall_rewrites}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo "4. Summary"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}PASSED: ${PASS}${NC}"
|
||||||
|
echo -e " ${RED}FAILED: ${FAIL}${NC}"
|
||||||
|
echo -e " ${YELLOW}WARNED: ${WARN}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ $FAIL -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}All checks passed! Dual-host support is working correctly.${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}Some checks failed. Please review the output above.${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main
|
||||||
|
main "$@"
|
||||||
80
scripts/verify-subdomains.sh
Executable file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify subdomain DNS propagation, SSL, and rewrite routing
|
||||||
|
# Run after DNS CNAMEs are added and Vercel domains are configured.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SUBDOMAINS=("nessa" "lineage" "gaze" "inputhalo")
|
||||||
|
APICAST_ROUTES=("Gaze" "InputHalo")
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local desc="$1"
|
||||||
|
shift
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
echo "✓ $desc"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "✗ $desc"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== DNS CNAME propagation ==="
|
||||||
|
for sub in "${SUBDOMAINS[@]}"; do
|
||||||
|
cname=$(dig +short "$sub.freno.me" 2>/dev/null | grep -i "cname.vercel-dns.com" || true)
|
||||||
|
if [[ -n "$cname" ]]; then
|
||||||
|
check "$sub.freno.me CNAME → cname.vercel-dns.com" true
|
||||||
|
else
|
||||||
|
check "$sub.freno.me CNAME → cname.vercel-dns.com" false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== HTTPS landing pages ==="
|
||||||
|
for sub in "${SUBDOMAINS[@]}"; do
|
||||||
|
status=$(curl -sI -o /dev/null -w "%{http_code}" "https://${sub}.freno.me/" 2>/dev/null || echo "000")
|
||||||
|
if [[ "$status" == "200" ]]; then
|
||||||
|
check "https://${sub}.freno.me/ → 200" true
|
||||||
|
else
|
||||||
|
check "https://${sub}.freno.me/ → 200 (got ${status})" false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Appcast regression (freno.me) ==="
|
||||||
|
for route in "${APICAST_ROUTES[@]}"; do
|
||||||
|
status=$(curl -sI -o /dev/null -w "%{http_code}" "https://freno.me/api/${route}/appcast.xml" 2>/dev/null || echo "000")
|
||||||
|
if [[ "$status" == "200" ]]; then
|
||||||
|
check "https://freno.me/api/${route}/appcast.xml → 200" true
|
||||||
|
else
|
||||||
|
check "https://freno.me/api/${route}/appcast.xml → 200 (got ${status})" false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Main site regression ==="
|
||||||
|
status=$(curl -sI -o /dev/null -w "%{http_code}" "https://freno.me/" 2>/dev/null || echo "000")
|
||||||
|
if [[ "$status" == "200" ]]; then
|
||||||
|
check "https://freno.me/ → 200" true
|
||||||
|
else
|
||||||
|
check "https://freno.me/ → 200 (got ${status})" false
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== SSL certificates ==="
|
||||||
|
for sub in "${SUBDOMAINS[@]}"; do
|
||||||
|
if echo | openssl s_client -connect "${sub}.freno.me:443" -servername "${sub}.freno.me" 2>/dev/null | \
|
||||||
|
openssl x509 -noout -checkend 0 2>/dev/null | grep -q "not expired"; then
|
||||||
|
check "${sub}.freno.me SSL valid" true
|
||||||
|
else
|
||||||
|
check "${sub}.freno.me SSL valid" false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary: ${PASS} passed, ${FAIL} failed ==="
|
||||||
|
if [[ $FAIL -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
306
src/app.css
@@ -74,6 +74,11 @@
|
|||||||
--color-base: #fbf1c7;
|
--color-base: #fbf1c7;
|
||||||
--color-mantle: #f3eac1;
|
--color-mantle: #f3eac1;
|
||||||
--color-crust: #e7deb7;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -104,6 +109,11 @@
|
|||||||
--color-base: #1e1e2e;
|
--color-base: #1e1e2e;
|
||||||
--color-mantle: #141620;
|
--color-mantle: #141620;
|
||||||
--color-crust: #0e0f16;
|
--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 {
|
@theme {
|
||||||
--color-rosewater: #efc9c2;
|
--color-rosewater: #efc9c2;
|
||||||
@@ -163,6 +173,8 @@
|
|||||||
--color-base: #fbf1c7;
|
--color-base: #fbf1c7;
|
||||||
--color-mantle: #f3eac1;
|
--color-mantle: #f3eac1;
|
||||||
--color-crust: #e7deb7;
|
--color-crust: #e7deb7;
|
||||||
|
--color-button-text: #ffffff;
|
||||||
|
--color-button-text-alt: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
@@ -192,6 +204,8 @@
|
|||||||
--color-base: #1e1e2e;
|
--color-base: #1e1e2e;
|
||||||
--color-mantle: #141620;
|
--color-mantle: #141620;
|
||||||
--color-crust: #0e0f16;
|
--color-crust: #0e0f16;
|
||||||
|
--color-button-text: var(--color-crust);
|
||||||
|
--color-button-text-alt: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -560,3 +574,295 @@ a.hover-underline-animation:hover::after {
|
|||||||
.shaker:hover {
|
.shaker:hover {
|
||||||
animation: shaker 0.5s ease;
|
animation: shaker 0.5s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Nook landing: campfire sprite frame cycling ──────────────────── */
|
||||||
|
/* Each frame layer is stacked; its animation holds opacity 1 during its
|
||||||
|
slot and 0 otherwise, so the stack reads as stop-motion at the app's
|
||||||
|
sprite cadence (frame time = total duration / frame count). The phase
|
||||||
|
lives inside the keyframes and every layer animates delay-free from the
|
||||||
|
same clock WebKit quantizes animation starts per cycle wrap, so
|
||||||
|
delay-offset layers can drop a wrap frame and show BOTH transparent
|
||||||
|
for a frame (the "blank frame" flicker). Complementary holds mean any
|
||||||
|
moment sums to exactly one opaque layer. Linear timing: steps()
|
||||||
|
holds mis-swap at boundary frames under WebKit. */
|
||||||
|
/* 2-frame sprites: layer i uses campfire-slot-2-{a,b}. */
|
||||||
|
@keyframes campfire-slot-2-a {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes campfire-slot-2-b {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 6-frame sprites (question): six slot phases. */
|
||||||
|
@keyframes campfire-slot-6-a {
|
||||||
|
0%,
|
||||||
|
16.66% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
16.67%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-b {
|
||||||
|
0%,
|
||||||
|
16.66% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
16.67%,
|
||||||
|
33.32% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
33.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-c {
|
||||||
|
0%,
|
||||||
|
33.32% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
33.33%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-d {
|
||||||
|
0%,
|
||||||
|
49.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
66.66% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
66.67%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-e {
|
||||||
|
0%,
|
||||||
|
66.66% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
66.67%,
|
||||||
|
83.32% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
83.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes campfire-slot-6-f {
|
||||||
|
0%,
|
||||||
|
83.32% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
83.33%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame {
|
||||||
|
animation: var(--slot-kf, campfire-slot-2-a) var(--campfire-duration, 1s)
|
||||||
|
linear infinite;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nook landing: breakdown section reveal ───────────────────────── */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
transition:
|
||||||
|
opacity 0.7s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal.in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campfire-frame + .campfire-frame {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ── Nook landing: fan card shake, builds with fan speed ───────── */
|
||||||
|
.fan-card-shake {
|
||||||
|
transform-origin: left center;
|
||||||
|
animation-name: fan-shake;
|
||||||
|
animation-duration: var(--fan-speed, 0.4s);
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fan-shake {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(calc(var(--fan-amp, 0deg) * -1)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sputtering sparks off the fan card at max speed */
|
||||||
|
.fan-spark {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #ffd23f;
|
||||||
|
box-shadow: 0 0 4px 1px rgba(255, 140, 0, 0.9);
|
||||||
|
animation: fan-spark-fly var(--sd, 0.5s) ease-out var(--sdel, 0s) infinite;
|
||||||
|
}
|
||||||
|
@keyframes fan-spark-fly {
|
||||||
|
0% { transform: translate(0, 0) scale(1); opacity: 0; }
|
||||||
|
10% { opacity: 1; }
|
||||||
|
100% { transform: translate(var(--sx, 20px), var(--sy, 30px)) scale(0.4); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Demo pulse: battery bolt */
|
||||||
|
@keyframes nook-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nook landing: newer demos ─────────────────────────────────── */
|
||||||
|
.nook-caret {
|
||||||
|
animation: nook-pulse 0.8s steps(2, jump-none) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live signal inside a mixer fader */
|
||||||
|
.nook-meter {
|
||||||
|
transform-origin: left center;
|
||||||
|
animation: nook-meter 0.6s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes nook-meter {
|
||||||
|
0% { transform: scaleX(0.35); }
|
||||||
|
40% { transform: scaleX(0.8); }
|
||||||
|
100% { transform: scaleX(0.55); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Waveform slot bars */
|
||||||
|
.nook-wave {
|
||||||
|
height: 100%;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: nook-wave 0.8s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes nook-wave {
|
||||||
|
0% { transform: scaleY(0.25); }
|
||||||
|
100% { transform: scaleY(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Now Playing progress and artwork sheen */
|
||||||
|
.nook-progress {
|
||||||
|
animation: nook-progress 14s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes nook-progress {
|
||||||
|
from { width: 8%; }
|
||||||
|
to { width: 100%; }
|
||||||
|
}
|
||||||
|
.nook-art {
|
||||||
|
animation: nook-art 9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes nook-art {
|
||||||
|
to { filter: hue-rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A ringing timer's tape breathes between 35 % and full */
|
||||||
|
.nook-tape-ring {
|
||||||
|
animation: nook-tape-ring 0.9s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
@keyframes nook-tape-ring {
|
||||||
|
from { opacity: 0.35; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep Awake coffee steam */
|
||||||
|
.nook-steam path {
|
||||||
|
animation: nook-steam 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes nook-steam {
|
||||||
|
0% { transform: translateY(2px); opacity: 0; }
|
||||||
|
40% { opacity: 1; }
|
||||||
|
100% { transform: translateY(-4px); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Two-way feature marquee; edges fade, hover pauses */
|
||||||
|
.nook-marquee {
|
||||||
|
overflow: hidden;
|
||||||
|
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
||||||
|
}
|
||||||
|
.nook-marquee-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: max-content;
|
||||||
|
padding-right: 0.625rem;
|
||||||
|
animation: nook-marquee 60s linear infinite;
|
||||||
|
}
|
||||||
|
.nook-marquee-track.reverse {
|
||||||
|
animation-direction: reverse;
|
||||||
|
}
|
||||||
|
.nook-marquee:hover .nook-marquee-track {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
@keyframes nook-marquee {
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.nook-meter,
|
||||||
|
.nook-wave,
|
||||||
|
.nook-progress,
|
||||||
|
.nook-art,
|
||||||
|
.nook-tape-ring,
|
||||||
|
.nook-steam path,
|
||||||
|
.nook-caret {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nook-marquee {
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nook-marquee-track {
|
||||||
|
animation: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
width: auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nook-marquee-track > [aria-hidden="true"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
36
src/app.tsx
@@ -5,16 +5,19 @@ import {
|
|||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
onMount,
|
onMount,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
|
Show,
|
||||||
Suspense
|
Suspense
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import { LeftBar, RightBar } from "./components/Bars";
|
import { LeftBar, RightBar } from "./components/Bars";
|
||||||
import { TerminalSplash } from "./components/TerminalSplash";
|
import { TerminalSplash } from "./components/TerminalSplash";
|
||||||
|
import SubdomainFooter from "./components/SubdomainFooter";
|
||||||
import { MetaProvider } from "@solidjs/meta";
|
import { MetaProvider } from "@solidjs/meta";
|
||||||
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
|
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback";
|
||||||
import { BarsProvider, useBars } from "./context/bars";
|
import { BarsProvider, useBars } from "./context/bars";
|
||||||
import { DarkModeProvider } from "./context/darkMode";
|
import { DarkModeProvider } from "./context/darkMode";
|
||||||
import { AuthProvider } from "./context/auth";
|
import { AuthProvider } from "./context/auth";
|
||||||
|
import { SiteProvider, useSite } from "./context/SiteContext";
|
||||||
import { createWindowWidth, isMobile } from "~/lib/resize-utils";
|
import { createWindowWidth, isMobile } from "~/lib/resize-utils";
|
||||||
import { MOBILE_CONFIG } from "./config";
|
import { MOBILE_CONFIG } from "./config";
|
||||||
import CustomScrollbar from "./components/CustomScrollbar";
|
import CustomScrollbar from "./components/CustomScrollbar";
|
||||||
@@ -32,10 +35,8 @@ function AppLayout(props: { children: any }) {
|
|||||||
let lastScrollY = 0;
|
let lastScrollY = 0;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Initialize performance tracking
|
|
||||||
initPerformanceTracking();
|
initPerformanceTracking();
|
||||||
|
|
||||||
// Start monitoring for new deployments
|
|
||||||
startDeploymentMonitoring();
|
startDeploymentMonitoring();
|
||||||
|
|
||||||
const windowWidth = createWindowWidth();
|
const windowWidth = createWindowWidth();
|
||||||
@@ -154,13 +155,17 @@ function AppLayout(props: { children: any }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const site = useSite();
|
||||||
|
const isMainSite = () => site().id === "main";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Show when={isMainSite()}>
|
||||||
<div class="flex max-w-screen flex-row overflow-x-hidden">
|
<div class="flex max-w-screen flex-row overflow-x-hidden">
|
||||||
<LeftBar />
|
<LeftBar />
|
||||||
<div
|
<div
|
||||||
id="center-body"
|
id="center-body"
|
||||||
class="bg-base relative h-screen w-screen overflow-x-hidden md:ml-62.5 md:w-[calc(100vw-500px)]"
|
class="bg-base relative h-screen w-screen overflow-x-hidden md:ml-[250px] md:w-[calc(100vw-500px)]"
|
||||||
>
|
>
|
||||||
<noscript>
|
<noscript>
|
||||||
<div class="bg-yellow text-crust border-text fixed top-0 z-150 border-b-2 p-4 text-center font-semibold md:w-[calc(100vw-500px)]">
|
<div class="bg-yellow text-crust border-text fixed top-0 z-150 border-b-2 p-4 text-center font-semibold md:w-[calc(100vw-500px)]">
|
||||||
@@ -190,6 +195,29 @@ function AppLayout(props: { children: any }) {
|
|||||||
</div>
|
</div>
|
||||||
<RightBar />
|
<RightBar />
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!isMainSite()}>
|
||||||
|
<div class="bg-base flex min-h-screen w-full flex-col 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 />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -203,6 +231,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DarkModeProvider>
|
<DarkModeProvider>
|
||||||
|
<SiteProvider>
|
||||||
<BarsProvider>
|
<BarsProvider>
|
||||||
<Router
|
<Router
|
||||||
root={(props) => (
|
root={(props) => (
|
||||||
@@ -214,6 +243,7 @@ export default function App() {
|
|||||||
<FileRoutes />
|
<FileRoutes />
|
||||||
</Router>
|
</Router>
|
||||||
</BarsProvider>
|
</BarsProvider>
|
||||||
|
</SiteProvider>
|
||||||
</DarkModeProvider>
|
</DarkModeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</MetaProvider>
|
</MetaProvider>
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { Typewriter } from "./Typewriter";
|
|||||||
import { useBars } from "~/context/bars";
|
import { useBars } from "~/context/bars";
|
||||||
import { useAuth } from "~/context/auth";
|
import { useAuth } from "~/context/auth";
|
||||||
import { revalidateAuth } from "~/lib/auth-query";
|
import { revalidateAuth } from "~/lib/auth-query";
|
||||||
import { onMount, createSignal, Show, For, onCleanup } from "solid-js";
|
import {
|
||||||
|
onMount,
|
||||||
|
createSignal,
|
||||||
|
Show,
|
||||||
|
For,
|
||||||
|
onCleanup,
|
||||||
|
type JSX
|
||||||
|
} from "solid-js";
|
||||||
import { api } from "~/lib/api";
|
import { api } from "~/lib/api";
|
||||||
import { insertSoftHyphens, glitchText } from "~/lib/client-utils";
|
import { insertSoftHyphens, glitchText } from "~/lib/client-utils";
|
||||||
import GitHub from "./icons/GitHub";
|
import GitHub from "./icons/GitHub";
|
||||||
@@ -14,6 +21,14 @@ import { SkeletonBox, SkeletonText } from "./SkeletonLoader";
|
|||||||
import { env } from "~/env/client";
|
import { env } from "~/env/client";
|
||||||
import { A, useNavigate, useLocation } from "@solidjs/router";
|
import { A, useNavigate, useLocation } from "@solidjs/router";
|
||||||
import { BREAKPOINTS } from "~/config";
|
import { BREAKPOINTS } from "~/config";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import {
|
||||||
|
NAV_CONFIG,
|
||||||
|
BACK_TO_FRENO,
|
||||||
|
filterNavByAuth,
|
||||||
|
type NavItem,
|
||||||
|
type NavIcon
|
||||||
|
} from "~/lib/nav-config";
|
||||||
|
|
||||||
function formatDomainName(url: string): string {
|
function formatDomainName(url: string): string {
|
||||||
const domain = url.split("://")[1]?.split(":")[0] ?? url;
|
const domain = url.split("://")[1]?.split(":")[0] ?? url;
|
||||||
@@ -46,7 +61,259 @@ interface ContributionDay {
|
|||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightBarContent() {
|
// Four independent cached promises — first RightBarContent instance to mount
|
||||||
|
// starts each fetch; the second gets the already-in-flight promise.
|
||||||
|
let ghCommitsPromise: Promise<GitCommit[]> | null = null;
|
||||||
|
let gtCommitsPromise: Promise<GitCommit[]> | null = null;
|
||||||
|
let ghActivityPromise: Promise<ContributionDay[]> | null = null;
|
||||||
|
let gtActivityPromise: Promise<ContributionDay[]> | null = null;
|
||||||
|
|
||||||
|
function getGhCommitsPromise(): Promise<GitCommit[]> {
|
||||||
|
return (ghCommitsPromise ??= api.gitActivity.getGitHubCommits
|
||||||
|
.query({ limit: 6 })
|
||||||
|
.catch(() => []));
|
||||||
|
}
|
||||||
|
function getGtCommitsPromise(): Promise<GitCommit[]> {
|
||||||
|
return (gtCommitsPromise ??= api.gitActivity.getGiteaCommits
|
||||||
|
.query({ limit: 6 })
|
||||||
|
.catch(() => []));
|
||||||
|
}
|
||||||
|
function getGhActivityPromise(): Promise<ContributionDay[]> {
|
||||||
|
return (ghActivityPromise ??= api.gitActivity.getGitHubActivity
|
||||||
|
.query()
|
||||||
|
.catch(() => []));
|
||||||
|
}
|
||||||
|
function getGtActivityPromise(): Promise<ContributionDay[]> {
|
||||||
|
return (gtActivityPromise ??= api.gitActivity.getGiteaActivity
|
||||||
|
.query()
|
||||||
|
.catch(() => []));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subdomain nav rendering ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The main site retains its bespoke LeftBar / RightBarContent rendering
|
||||||
|
// unchanged (Recent Posts, auth-aware Account/Login/SignOut, admin links,
|
||||||
|
// RecentCommits + ActivityHeatmap widgets, the "What's this?" glitch button).
|
||||||
|
// Subdomain sites render a simplified, brand-colored shell that iterates
|
||||||
|
// `NAV_CONFIG[site]` + a "back to freno.me" affordance, and deliberately
|
||||||
|
// skips the web-auth (freno.me JWT) widgets — Nessa uses Clerk; Lineage uses
|
||||||
|
// its mobile JWT; neither should surface web login state.
|
||||||
|
|
||||||
|
/** Inline icon resolver keyed by `NavIcon` (kept out of the pure nav-config). */
|
||||||
|
function NavIconSvg(props: { icon?: NavIcon; size?: number }): JSX.Element {
|
||||||
|
const size = () => props.size ?? 22;
|
||||||
|
const cls = "shaker rounded-full p-2";
|
||||||
|
const common = (viewBox: string, path: JSX.Element) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height={size()}
|
||||||
|
width={size()}
|
||||||
|
viewBox={viewBox}
|
||||||
|
class="fill-text"
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
switch (props.icon) {
|
||||||
|
case "home":
|
||||||
|
return common(
|
||||||
|
"0 0 576 512",
|
||||||
|
<path d="M543.8 287.6c17 0 32-14 32-32.1 1-9-3-17-11-24L309.5 7c-6-5-14-7-21-7s-15 1-22 8L10 231.5c-7 7-10 15-9 24 0 18 14 32.1 32 32.1l32 0V448c0 35.3 28.7 64 64 64l224 0c35.3 0 64-28.7 64-64l0-160.4 50.8 0zM272 448v-96c0-17.7 14.3-32 32-32s32 14.3 32 32v96H272z" />
|
||||||
|
);
|
||||||
|
case "blog":
|
||||||
|
return common(
|
||||||
|
"0 0 448 512",
|
||||||
|
<path d="M448 336v-288C448 21.49 426.5 0 400 0H96C42.98 0 0 42.98 0 96v320c0 53.02 42.98 96 96 96h320c17.67 0 32-14.33 32-32s-14.33-32-32-32H96c-17.67 0-32-14.33-32-32s14.33-32 32-32h320C426.5 416 448 394.5 448 336zM96 352c-11.28 0-21.94 2.564-32 6.879V96c0-17.67 14.33-32 32-32h320v256H96z" />
|
||||||
|
);
|
||||||
|
case "downloads":
|
||||||
|
return common(
|
||||||
|
"0 0 512 512",
|
||||||
|
<path d="M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32V274.7l-73.4-73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l128-128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L288 274.7V32zM64 352c-35.3 0-64 28.7-64 64v32c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V416c0-35.3-28.7-64-64-64H346.5l-45.3 45.3c-25 25-65.5 25-90.5 0L165.5 352H64zm368 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z" />
|
||||||
|
);
|
||||||
|
case "resume":
|
||||||
|
return common(
|
||||||
|
"0 0 384 512",
|
||||||
|
<path d="M64 0C28.7 0 0 28.7 0 64V448c0 35.3 28.7 64 64 64H320c35.3 0 64-28.7 64-64V160H256c-17.7 0-32-14.3-32-32V0H64zM256 0V128H384L256 0zM112 256H272c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64H272c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64H272c8.8 0 16 7.2 16 16s-7.2 16-16 16H112c-8.8 0-16-7.2-16-16s7.2-16 16-16z" />
|
||||||
|
);
|
||||||
|
case "contact":
|
||||||
|
return common(
|
||||||
|
"0 0 512 512",
|
||||||
|
<path d="M96 0C60.7 0 32 28.7 32 64V448c0 35.3 28.7 64 64 64H416c35.3 0 64-28.7 64-64V64c0-35.3-28.7-64-64-64H96zM208 256h96c26.5 0 48 21.5 48 48v8c0 44.2-35.8 80-80 80H240c-44.2 0-80-35.8-80-80v-8c0-26.5 21.5-48 48-48zm48-48c26.5 0 48-21.5 48-48s-21.5-48-48-48-48 21.5-48 48 21.5 48 48 48z" />
|
||||||
|
);
|
||||||
|
case "privacy":
|
||||||
|
return common(
|
||||||
|
"0 0 512 512",
|
||||||
|
<path d="M256 0c4.6 0 9.2 1 13.4 2.9L457.7 82.8c22 9.3 38.4 31 38.3 57.2c-.5 99.2-41.3 280.7-213.6 363.2c-16.7 8-36.1 8-52.8 0C57.3 420.7 16.5 239.2 16 140c-.1-26.2 16.3-47.9 38.3-57.2L242.6 2.9C246.8 1 251.4 0 256 0z" />
|
||||||
|
);
|
||||||
|
case "deletion":
|
||||||
|
return common(
|
||||||
|
"0 0 448 512",
|
||||||
|
<path d="M135.2 17.7L128 32H32C14.3 32 0 46.3 0 64S14.3 96 32 96H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H320l-7.2-14.3C307.4 6.8 296.3 0 284.2 0H163.8c-12.1 0-23.2 6.8-28.6 17.7zM416 128H32L53.2 467c1.6 25.3 22.6 45 47.9 45H346.9c25.3 0 46.3-19.7 47.9-45L416 128z" />
|
||||||
|
);
|
||||||
|
case "back":
|
||||||
|
return common(
|
||||||
|
"0 0 512 512",
|
||||||
|
<path d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256S114.6 512 256 512s256-114.6 256-256zM116.7 244.7l112-112c4.6-4.6 11.9-5.9 17.1-2.5s7.7 10 4.9 16.4l-21.5 50.3H376c13.3 0 24 10.7 24 24s-10.7 24-24 24H229.2l21.5 50.3c2.8 6.4 .3 13-4.9 16.4s-12.5 2.1-17.1-2.5l-112-112c-6.2-6.2-6.2-16.3 0-22.6z" />
|
||||||
|
);
|
||||||
|
case "github":
|
||||||
|
return <GitHub height={22} width={22} fill={`var(--color-text)`} />;
|
||||||
|
case "linkedin":
|
||||||
|
return <LinkedIn height={22} width={22} fill={undefined} />;
|
||||||
|
default:
|
||||||
|
return <span class={cls} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subdomain nav link — renders an internal (`A`) or external (`a`) link with
|
||||||
|
* a resolved icon. Uses the same hover affordances as the main-site links.
|
||||||
|
*/
|
||||||
|
function SubdomainNavLink(props: { item: NavItem; onClick: () => void }) {
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<Show when={props.item.icon}>
|
||||||
|
<span class="shaker rounded-full p-2">
|
||||||
|
<NavIconSvg icon={props.item.icon} />
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span>{props.item.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (props.item.external) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={props.item.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="hover:text-subtext0 flex items-center gap-3 transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<A
|
||||||
|
href={props.item.href}
|
||||||
|
onClick={props.onClick}
|
||||||
|
class="hover:text-subtext0 flex items-center gap-3 transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</A>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shared simplified nav list rendered by both subdomain bars. */
|
||||||
|
function SubdomainNavList(props: { onClick: () => void }) {
|
||||||
|
const site = useSite();
|
||||||
|
// Auth items intentionally omitted — subdomains don't use the web (freno.me)
|
||||||
|
// JWT auth (Nessa uses Clerk; Lineage uses its mobile JWT), so no subdomain
|
||||||
|
// nav item sets showLoggedIn/showLoggedOut today. filterNavByAuth is therefore
|
||||||
|
// a no-op right now but is kept so future admin items behave correctly
|
||||||
|
// without re-touching the renderer. Filtering here keeps LeftBar + RightBar
|
||||||
|
// consistent.
|
||||||
|
return (
|
||||||
|
<For each={filterNavByAuth(NAV_CONFIG[site().id], false)}>
|
||||||
|
{(item) => <SubdomainNavLink item={item} onClick={props.onClick} />}
|
||||||
|
</For>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brand heading — display name in the site's brand color. */
|
||||||
|
function SubdomainBrand() {
|
||||||
|
const site = useSite();
|
||||||
|
const accent = () => `color: ${site().brandColor}`;
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
class="w-fit pt-6 text-center text-3xl underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||||
|
style={accent()}
|
||||||
|
>
|
||||||
|
<A href="/">{site().displayName}</A>
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Back to freno.me" affordance rendered on every subdomain site. */
|
||||||
|
function BackToFrenoLink() {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={BACK_TO_FRENO.href}
|
||||||
|
class="hover:text-subtext0 flex items-center gap-3 text-sm transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105"
|
||||||
|
>
|
||||||
|
<span class="shaker rounded-full p-2">
|
||||||
|
<NavIconSvg icon="back" size={18} />
|
||||||
|
</span>
|
||||||
|
<span>{BACK_TO_FRENO.label}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner content for the LeftBar on subdomain sites. */
|
||||||
|
function SubdomainLeftBarContent() {
|
||||||
|
const { setLeftBarVisible } = useBars();
|
||||||
|
const handleLinkClick = () => {
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||||
|
) {
|
||||||
|
setLeftBarVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div class="text-text flex h-full flex-col px-4 pb-4 text-xl font-bold">
|
||||||
|
<SubdomainBrand />
|
||||||
|
<div class="flex flex-col gap-4 py-8">
|
||||||
|
{/* Auth items intentionally omitted — see SubdomainNavList. */}
|
||||||
|
<SubdomainNavList onClick={handleLinkClick} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto flex flex-col gap-4">
|
||||||
|
<BackToFrenoLink />
|
||||||
|
<hr class="border-overlay0" />
|
||||||
|
<DarkModeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile-only secondary column mirror of the right bar. */}
|
||||||
|
<div class="border-overlay0 -mx-4 mt-4 border-t pt-8 md:hidden">
|
||||||
|
<SubdomainRightBarContent />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner content for the RightBar on subdomain sites (desktop only). */
|
||||||
|
function SubdomainRightBarContent() {
|
||||||
|
const { setLeftBarVisible } = useBars();
|
||||||
|
const handleLinkClick = () => {
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||||
|
) {
|
||||||
|
setLeftBarVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="rightbar-content"
|
||||||
|
class="text-text flex h-full flex-col gap-6 overflow-y-auto pb-6 md:w-min"
|
||||||
|
>
|
||||||
|
<Typewriter keepAlive={false} class="z-50 px-4 md:pt-4">
|
||||||
|
<SubdomainBrand />
|
||||||
|
</Typewriter>
|
||||||
|
|
||||||
|
<hr class="border-overlay0" />
|
||||||
|
<ul class="flex flex-col gap-4 px-4">
|
||||||
|
<SubdomainNavList onClick={handleLinkClick} />
|
||||||
|
</ul>
|
||||||
|
<hr class="border-overlay0" />
|
||||||
|
<div class="flex flex-col gap-4 px-4">
|
||||||
|
<BackToFrenoLink />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main-site RightBar (unchanged) ───────────────────────────────────────
|
||||||
|
function MainRightBarContent() {
|
||||||
const { setLeftBarVisible } = useBars();
|
const { setLeftBarVisible } = useBars();
|
||||||
const [githubCommits, setGithubCommits] = createSignal<GitCommit[]>([]);
|
const [githubCommits, setGithubCommits] = createSignal<GitCommit[]>([]);
|
||||||
const [giteaCommits, setGiteaCommits] = createSignal<GitCommit[]>([]);
|
const [giteaCommits, setGiteaCommits] = createSignal<GitCommit[]>([]);
|
||||||
@@ -54,7 +321,8 @@ export function RightBarContent() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const [giteaActivity, setGiteaActivity] = createSignal<ContributionDay[]>([]);
|
const [giteaActivity, setGiteaActivity] = createSignal<ContributionDay[]>([]);
|
||||||
const [loading, setLoading] = createSignal(true);
|
const [githubCommitsLoading, setGithubCommitsLoading] = createSignal(true);
|
||||||
|
const [giteaCommitsLoading, setGiteaCommitsLoading] = createSignal(true);
|
||||||
|
|
||||||
const handleLinkClick = () => {
|
const handleLinkClick = () => {
|
||||||
if (
|
if (
|
||||||
@@ -66,41 +334,23 @@ export function RightBarContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
// Fetch more commits to account for deduplication
|
|
||||||
const [ghCommits, gtCommits, ghActivity, gtActivity] =
|
|
||||||
await Promise.all([
|
|
||||||
api.gitActivity.getGitHubCommits
|
|
||||||
.query({ limit: 6 })
|
|
||||||
.catch(() => []),
|
|
||||||
api.gitActivity.getGiteaCommits.query({ limit: 6 }).catch(() => []),
|
|
||||||
api.gitActivity.getGitHubActivity.query().catch(() => []),
|
|
||||||
api.gitActivity.getGiteaActivity.query().catch(() => [])
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Take first 3 from GitHub
|
|
||||||
const displayedGithubCommits = ghCommits.slice(0, 3);
|
|
||||||
|
|
||||||
// Deduplicate Gitea commits - only against the 3 shown in GitHub section
|
|
||||||
const githubShas = new Set(displayedGithubCommits.map((c) => c.sha));
|
|
||||||
const uniqueGiteaCommits = gtCommits.filter(
|
|
||||||
(commit) => !githubShas.has(commit.sha)
|
|
||||||
);
|
|
||||||
|
|
||||||
setGithubCommits(displayedGithubCommits);
|
|
||||||
setGiteaCommits(uniqueGiteaCommits.slice(0, 3));
|
|
||||||
setGithubActivity(ghActivity);
|
|
||||||
setGiteaActivity(gtActivity);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch git activity:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fetchData();
|
getGhCommitsPromise().then((commits) => {
|
||||||
|
setGithubCommits(commits.slice(0, 3));
|
||||||
|
setGithubCommitsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduplicate Gitea against whatever GitHub has resolved by the time this lands
|
||||||
|
getGtCommitsPromise().then((gtCommits) => {
|
||||||
|
const ghShas = new Set(githubCommits().map((c) => c.sha));
|
||||||
|
setGiteaCommits(
|
||||||
|
gtCommits.filter((c) => !ghShas.has(c.sha)).slice(0, 3)
|
||||||
|
);
|
||||||
|
setGiteaCommitsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
getGhActivityPromise().then((activity) => setGithubActivity(activity));
|
||||||
|
getGtActivityPromise().then((activity) => setGiteaActivity(activity));
|
||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,6 +366,11 @@ export function RightBarContent() {
|
|||||||
Contact Me
|
Contact Me
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="hover:text-subtext0 w-fit transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-110 hover:font-bold">
|
||||||
|
<a href="/privacy-policy" onClick={handleLinkClick}>
|
||||||
|
Privacy
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/MikeFreno/"
|
href="https://github.com/MikeFreno/"
|
||||||
@@ -187,30 +442,40 @@ export function RightBarContent() {
|
|||||||
|
|
||||||
<hr class="border-overlay0" />
|
<hr class="border-overlay0" />
|
||||||
<div class="flex min-w-0 flex-col gap-6 px-4 pt-6">
|
<div class="flex min-w-0 flex-col gap-6 px-4 pt-6">
|
||||||
<RecentCommits
|
|
||||||
commits={githubCommits()}
|
|
||||||
title="Recent GitHub Commits"
|
|
||||||
loading={loading()}
|
|
||||||
/>
|
|
||||||
<ActivityHeatmap
|
|
||||||
contributions={githubActivity()}
|
|
||||||
title="GitHub Activity"
|
|
||||||
/>
|
|
||||||
<RecentCommits
|
<RecentCommits
|
||||||
commits={giteaCommits()}
|
commits={giteaCommits()}
|
||||||
title="Recent Gitea Commits"
|
title="Recent Gitea Commits"
|
||||||
loading={loading()}
|
loading={giteaCommitsLoading()}
|
||||||
/>
|
/>
|
||||||
<ActivityHeatmap
|
<ActivityHeatmap
|
||||||
contributions={giteaActivity()}
|
contributions={giteaActivity()}
|
||||||
title="Gitea Activity"
|
title="Gitea Activity"
|
||||||
/>
|
/>
|
||||||
|
<RecentCommits
|
||||||
|
commits={githubCommits()}
|
||||||
|
title="Recent GitHub Commits"
|
||||||
|
loading={githubCommitsLoading()}
|
||||||
|
/>
|
||||||
|
<ActivityHeatmap
|
||||||
|
contributions={githubActivity()}
|
||||||
|
title="GitHub Activity"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeftBar() {
|
function RightBarContent() {
|
||||||
|
const site = useSite();
|
||||||
|
return (
|
||||||
|
<Show when={site().id === "main"} fallback={<SubdomainRightBarContent />}>
|
||||||
|
<MainRightBarContent />
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main-site LeftBar content (unchanged) ────────────────────────────────
|
||||||
|
function MainLeftBarContent() {
|
||||||
const { leftBarVisible, setLeftBarVisible } = useBars();
|
const { leftBarVisible, setLeftBarVisible } = useBars();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isAuthenticated, email, isAdmin } = useAuth();
|
const { isAuthenticated, email, isAdmin } = useAuth();
|
||||||
@@ -224,11 +489,6 @@ export function LeftBar() {
|
|||||||
const [signOutLoading, setSignOutLoading] = createSignal(false);
|
const [signOutLoading, setSignOutLoading] = createSignal(false);
|
||||||
const [getLostText, setGetLostText] = createSignal("What's this?");
|
const [getLostText, setGetLostText] = createSignal("What's this?");
|
||||||
const [getLostVisible, setGetLostVisible] = createSignal(false);
|
const [getLostVisible, setGetLostVisible] = createSignal(false);
|
||||||
const [windowWidth, setWindowWidth] = createSignal(
|
|
||||||
typeof window !== "undefined"
|
|
||||||
? window.innerWidth
|
|
||||||
: BREAKPOINTS.MOBILE_MAX_WIDTH
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleLinkClick = () => {
|
const handleLinkClick = () => {
|
||||||
if (
|
if (
|
||||||
@@ -254,11 +514,6 @@ export function LeftBar() {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
setWindowWidth(window.innerWidth);
|
|
||||||
};
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
|
|
||||||
const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`";
|
const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`";
|
||||||
const originalText = "What's this?";
|
const originalText = "What's this?";
|
||||||
let glitchInterval: NodeJS.Timeout;
|
let glitchInterval: NodeJS.Timeout;
|
||||||
@@ -299,54 +554,6 @@ export function LeftBar() {
|
|||||||
animationFrame = requestAnimationFrame(revealAnimation);
|
animationFrame = requestAnimationFrame(revealAnimation);
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
if (ref) {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const isMobile = window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH;
|
|
||||||
|
|
||||||
if (!isMobile || !leftBarVisible()) return;
|
|
||||||
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
const focusableElements = ref?.querySelectorAll(
|
|
||||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!focusableElements || focusableElements.length === 0) return;
|
|
||||||
|
|
||||||
const firstElement = focusableElements[0] as HTMLElement;
|
|
||||||
const lastElement = focusableElements[
|
|
||||||
focusableElements.length - 1
|
|
||||||
] as HTMLElement;
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
if (document.activeElement === firstElement) {
|
|
||||||
e.preventDefault();
|
|
||||||
lastElement.focus();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (document.activeElement === lastElement) {
|
|
||||||
e.preventDefault();
|
|
||||||
firstElement.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ref.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
ref?.removeEventListener("keydown", handleKeyDown);
|
|
||||||
clearInterval(glitchInterval);
|
|
||||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onCleanup(() => {
|
|
||||||
clearInterval(glitchInterval);
|
|
||||||
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const posts = await api.blog.getRecentPosts.query();
|
const posts = await api.blog.getRecentPosts.query();
|
||||||
@@ -363,62 +570,9 @@ export function LeftBar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const getMainNavStyles = () => {
|
|
||||||
const baseStyles = {
|
|
||||||
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
|
||||||
width: "250px",
|
|
||||||
"padding-top": "env(safe-area-inset-top)",
|
|
||||||
"padding-bottom": "env(safe-area-inset-bottom)"
|
|
||||||
};
|
|
||||||
|
|
||||||
const shadowStyle =
|
|
||||||
windowWidth() >= BREAKPOINTS.MOBILE_MAX_WIDTH
|
|
||||||
? { "box-shadow": "inset -6px 0 16px -6px rgba(0, 0, 0, 0.1)" }
|
|
||||||
: { "box-shadow": "0 10px 10px 0 rgba(0, 0, 0, 0.2)" };
|
|
||||||
|
|
||||||
return { ...baseStyles, ...shadowStyle };
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<>
|
||||||
id="navigation"
|
|
||||||
tabindex="-1"
|
|
||||||
ref={ref}
|
|
||||||
aria-label="Main navigation"
|
|
||||||
class="border-r-overlay2 bg-base fixed z-200 h-dvh border-r-2 transition-transform duration-500 ease-out"
|
|
||||||
classList={{
|
|
||||||
"-translate-x-full": !leftBarVisible(),
|
|
||||||
"translate-x-0": leftBarVisible()
|
|
||||||
}}
|
|
||||||
style={getMainNavStyles()}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setLeftBarVisible(!leftBarVisible())}
|
|
||||||
class="hamburger-menu-btn absolute top-4 -right-14 z-9999 rounded-md p-2 shadow-md backdrop-blur-2xl transition-transform duration-600 ease-in-out hover:scale-110"
|
|
||||||
classList={{
|
|
||||||
hidden: leftBarVisible()
|
|
||||||
}}
|
|
||||||
aria-label="Toggle navigation menu"
|
|
||||||
style={{
|
|
||||||
display: "none"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="text-text h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-y-auto">
|
|
||||||
<Typewriter speed={10} keepAlive={10000} class="z-50 pr-8 pl-4">
|
<Typewriter speed={10} keepAlive={10000} class="z-50 pr-8 pl-4">
|
||||||
<h3 class="hover:text-subtext0 w-fit pt-6 text-center text-3xl underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105">
|
<h3 class="hover:text-subtext0 w-fit pt-6 text-center text-3xl underline transition-transform duration-200 ease-in-out hover:-translate-y-0.5 hover:scale-105">
|
||||||
<a href="/" onClick={handleLinkClick}>
|
<a href="/" onClick={handleLinkClick}>
|
||||||
@@ -426,7 +580,6 @@ export function LeftBar() {
|
|||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
</Typewriter>
|
</Typewriter>
|
||||||
|
|
||||||
<div class="text-text flex flex-1 flex-col px-4 pb-4 text-xl font-bold">
|
<div class="text-text flex flex-1 flex-col px-4 pb-4 text-xl font-bold">
|
||||||
<div class="flex flex-col py-8">
|
<div class="flex flex-col py-8">
|
||||||
<span class="text-lg font-semibold">Recent Posts</span>
|
<span class="text-lg font-semibold">Recent Posts</span>
|
||||||
@@ -594,6 +747,140 @@ export function LeftBar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftBar() {
|
||||||
|
const { leftBarVisible, setLeftBarVisible } = useBars();
|
||||||
|
const site = useSite();
|
||||||
|
let ref: HTMLDivElement | undefined;
|
||||||
|
|
||||||
|
const [windowWidth, setWindowWidth] = createSignal(
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.innerWidth
|
||||||
|
: BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setWindowWidth(window.innerWidth);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
if (ref) {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const isMobile = window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH;
|
||||||
|
|
||||||
|
if (!isMobile || !leftBarVisible()) return;
|
||||||
|
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
const focusableElements = ref?.querySelectorAll(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!focusableElements || focusableElements.length === 0) return;
|
||||||
|
|
||||||
|
const firstElement = focusableElements[0] as HTMLElement;
|
||||||
|
const lastElement = focusableElements[
|
||||||
|
focusableElements.length - 1
|
||||||
|
] as HTMLElement;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastElement.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
firstElement.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ref.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
ref?.removeEventListener("keydown", handleKeyDown);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMainNavStyles = () => {
|
||||||
|
const baseStyles = {
|
||||||
|
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
|
width: "250px",
|
||||||
|
"padding-top": "env(safe-area-inset-top)",
|
||||||
|
"padding-bottom": "env(safe-area-inset-bottom)"
|
||||||
|
};
|
||||||
|
|
||||||
|
const shadowStyle =
|
||||||
|
windowWidth() >= BREAKPOINTS.MOBILE_MAX_WIDTH
|
||||||
|
? { "box-shadow": "inset -6px 0 16px -6px rgba(0, 0, 0, 0.1)" }
|
||||||
|
: { "box-shadow": "0 10px 10px 0 rgba(0, 0, 0, 0.2)" };
|
||||||
|
|
||||||
|
return { ...baseStyles, ...shadowStyle };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subdomain sites sport an accent border / shadow tinted by the brand color
|
||||||
|
// ("bars render appropriately styled per site — brand color hint from
|
||||||
|
// SITE_CONFIG"). Main keeps the existing neutral styling.
|
||||||
|
const accentBorder = () =>
|
||||||
|
site().id === "main" ? undefined : { "border-color": site().brandColor };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
id="navigation"
|
||||||
|
tabindex="-1"
|
||||||
|
ref={ref}
|
||||||
|
aria-label="Main navigation"
|
||||||
|
class="border-r-overlay2 bg-base fixed z-200 h-dvh border-r-2 transition-transform duration-500 ease-out"
|
||||||
|
classList={{
|
||||||
|
"-translate-x-full": !leftBarVisible(),
|
||||||
|
"translate-x-0": leftBarVisible()
|
||||||
|
}}
|
||||||
|
style={{ ...getMainNavStyles(), ...accentBorder() }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setLeftBarVisible(!leftBarVisible())}
|
||||||
|
class="hamburger-menu-btn absolute top-4 -right-14 z-9999 rounded-md p-2 shadow-md backdrop-blur-2xl transition-transform duration-600 ease-in-out hover:scale-110"
|
||||||
|
classList={{
|
||||||
|
hidden: leftBarVisible()
|
||||||
|
}}
|
||||||
|
aria-label="Toggle navigation menu"
|
||||||
|
style={{
|
||||||
|
display: "none"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="text-text h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex h-full flex-col overflow-y-auto">
|
||||||
|
<Show
|
||||||
|
when={site().id === "main"}
|
||||||
|
fallback={<SubdomainLeftBarContent />}
|
||||||
|
>
|
||||||
|
<MainLeftBarContent />
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
@@ -616,6 +903,7 @@ export function RightBar() {
|
|||||||
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
"transition-timing-function": "cubic-bezier(0.4, 0, 0.2, 1)",
|
||||||
"box-shadow": "inset 6px 0 16px -6px rgba(0, 0, 0, 0.1)",
|
"box-shadow": "inset 6px 0 16px -6px rgba(0, 0, 0, 0.1)",
|
||||||
"padding-top": "env(safe-area-inset-top)",
|
"padding-top": "env(safe-area-inset-top)",
|
||||||
|
width: "250px",
|
||||||
"padding-bottom": "env(safe-area-inset-bottom)",
|
"padding-bottom": "env(safe-area-inset-bottom)",
|
||||||
"scrollbar-width": "none"
|
"scrollbar-width": "none"
|
||||||
}}
|
}}
|
||||||
|
|||||||
544
src/components/ContactForm.tsx
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
import { createSignal, onMount, createEffect, Show, type JSX } from "solid-js";
|
||||||
|
import { useSearchParams, query, createAsync } from "@solidjs/router";
|
||||||
|
import { action, redirect } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { api } from "~/lib/api";
|
||||||
|
import { getClientCookie } from "~/lib/cookies.client";
|
||||||
|
import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
||||||
|
import Input from "~/components/ui/Input";
|
||||||
|
import { Button } from "~/components/ui/Button";
|
||||||
|
import { useCountdown } from "~/lib/useCountdown";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import type { UserProfile } from "~/types/user";
|
||||||
|
import { getCookie, setCookie } from "vinxi/http";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { env as clientEnv } from "~/env/client";
|
||||||
|
import {
|
||||||
|
fetchWithTimeout,
|
||||||
|
checkResponse,
|
||||||
|
fetchWithRetry,
|
||||||
|
NetworkError,
|
||||||
|
TimeoutError,
|
||||||
|
APIError,
|
||||||
|
verifyTurnstileToken
|
||||||
|
} from "~/server/fetch-utils";
|
||||||
|
import {
|
||||||
|
NETWORK_CONFIG,
|
||||||
|
COOLDOWN_TIMERS,
|
||||||
|
VALIDATION_CONFIG,
|
||||||
|
COUNTDOWN_CONFIG,
|
||||||
|
TURNSTILE_CONFIG
|
||||||
|
} from "~/config";
|
||||||
|
import {
|
||||||
|
CONTACT_RECIPIENT_EMAIL,
|
||||||
|
CONTACT_SENDER,
|
||||||
|
getContactContext,
|
||||||
|
buildContactSubject
|
||||||
|
} from "~/lib/contact-config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared, site-aware contact form — per-subdomain contact pages.
|
||||||
|
*
|
||||||
|
* Extracted verbatim-in-spirit from the legacy `src/routes/contact.tsx` so the
|
||||||
|
* main-site contact flow (`freno.me/contact`) keeps its exact Turnstile +
|
||||||
|
* cooldown + tRPC-submission behavior — the only substantive change is that
|
||||||
|
* the outbound email subject is now per-site (see `~/lib/contact-config.ts`)
|
||||||
|
* and `env` is resolved via a server-only dynamic import (the legacy
|
||||||
|
* top-level `env` reference was a latent runtime bug in the no-JS fallback).
|
||||||
|
*
|
||||||
|
* Site awareness:
|
||||||
|
* - Reads `useSite()` and resolves a default `ContactContext` from
|
||||||
|
* `CONTACT_CONTEXT[site().id]` (subjectPrefix, recipientLabel, heading,
|
||||||
|
* PageHead title + description). Props override the defaults.
|
||||||
|
* - Emits `<PageHead>` so every per-subdomain `/contact` route gets
|
||||||
|
* site-aware title / canonical / OG tags for free.
|
||||||
|
* - The Turnstile site key (`VITE_TURNSTILE_SITE_KEY`) is shared across all
|
||||||
|
* subdomains — ensure it is configured for `*.freno.me` in the Cloudflare
|
||||||
|
* Turnstile dashboard.
|
||||||
|
*
|
||||||
|
* Email routing:
|
||||||
|
* - JS path: `api.misc.sendContactRequest.mutate({ …, subjectPrefix })` — the
|
||||||
|
* tRPC mutation builds the subject via `buildContactSubject`.
|
||||||
|
* - No-JS path: the `sendContactEmail` server action reads a hidden
|
||||||
|
* `subjectPrefix` form field and emits the identical subject. Both paths
|
||||||
|
* deliver to the single shared `CONTACT_RECIPIENT_EMAIL` inbox.
|
||||||
|
*
|
||||||
|
* Both redirect targets (`/contact?success=true`, `/contact?error=…`) are the
|
||||||
|
* PUBLIC browser path — correct on every subdomain origin since vercel.json
|
||||||
|
* host rewrites leave the browser URL clean (`nessa.freno.me/contact`).
|
||||||
|
*/
|
||||||
|
export interface ContactFormProps {
|
||||||
|
/**
|
||||||
|
* Outbound email subject prefix token. Defaults to the active site's
|
||||||
|
* `CONTACT_CONTEXT[siteId].subjectPrefix` (e.g. `"freno.me"` on main,
|
||||||
|
* `"[Nessa]"` on nessa).
|
||||||
|
*/
|
||||||
|
subjectPrefix?: string;
|
||||||
|
/** Display-only label for the recipient. Defaults to the site context. */
|
||||||
|
recipientLabel?: string;
|
||||||
|
/** `<h1>` heading. Defaults to the site context's `heading` (`"Contact"`). */
|
||||||
|
heading?: string;
|
||||||
|
/** Optional subline rendered under the heading (e.g. main-site disclaimer). */
|
||||||
|
subline?: JSX.Element;
|
||||||
|
/**
|
||||||
|
* Extra content rendered between the heading/subline and the form — used by
|
||||||
|
* the main site and the lineage subdomain to host the Life-and-Lineage Q&A
|
||||||
|
* accordion.
|
||||||
|
*/
|
||||||
|
children?: JSX.Element;
|
||||||
|
/** `<PageHead title>` — composes with the site `titleSuffix`. */
|
||||||
|
pageTitle?: string;
|
||||||
|
/** `<PageHead description>`. Defaults to the site context's description. */
|
||||||
|
pageDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// Server data query — cooldown cookie expiry (shared across all sites).
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
const getContactData = query(async () => {
|
||||||
|
"use server";
|
||||||
|
const contactExp = getCookie("contactRequestSent");
|
||||||
|
let remainingTime = 0;
|
||||||
|
|
||||||
|
if (contactExp) {
|
||||||
|
const expires = new Date(contactExp);
|
||||||
|
remainingTime = Math.max(0, (expires.getTime() - Date.now()) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { remainingTime };
|
||||||
|
}, "contact-data");
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// No-JS fallback action. Behaves identically to the tRPC mutation so the
|
||||||
|
// contact form works even with JS disabled (progressive enhancement).
|
||||||
|
//
|
||||||
|
// `env` is resolved via a server-only dynamic import (the idiomatic pattern
|
||||||
|
// used by `account.tsx` / `blog/index.tsx`) — the legacy top-level `env`
|
||||||
|
// reference in the original `contact.tsx` was a latent runtime bug.
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
const sendContactEmail = action(async (formData: FormData) => {
|
||||||
|
"use server";
|
||||||
|
const name = formData.get("name") as string;
|
||||||
|
const email = formData.get("email") as string;
|
||||||
|
const message = formData.get("message") as string;
|
||||||
|
const turnstileToken = formData.get("cf-turnstile-response") as string;
|
||||||
|
const subjectPrefix =
|
||||||
|
(formData.get("subjectPrefix") as string | null) || "freno.me";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
email: z.string().email("Valid email is required"),
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Message is required")
|
||||||
|
.max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH, "Message too long")
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
schema.parse({ name, email, message });
|
||||||
|
} catch (err: any) {
|
||||||
|
return redirect(
|
||||||
|
`/contact?error=${encodeURIComponent(err.errors[0]?.message || "Invalid input")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { env } = await import("~/env/server");
|
||||||
|
|
||||||
|
const turnstileValid = await verifyTurnstileToken(
|
||||||
|
turnstileToken,
|
||||||
|
env.TURNSTILE_SECRET_KEY,
|
||||||
|
TURNSTILE_CONFIG.VERIFY_URL,
|
||||||
|
TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!turnstileValid) {
|
||||||
|
return redirect(
|
||||||
|
"/contact?error=Security verification failed. Please refresh and try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactExp = getCookie("contactRequestSent");
|
||||||
|
if (contactExp) {
|
||||||
|
const expires = new Date(contactExp);
|
||||||
|
const remaining = expires.getTime() - Date.now();
|
||||||
|
if (remaining > 0) {
|
||||||
|
return redirect(
|
||||||
|
"/contact?error=Please wait before sending another message"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = env.SENDINBLUE_KEY;
|
||||||
|
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
|
||||||
|
|
||||||
|
const escapeHtml = (str: string) =>
|
||||||
|
str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
const sendinblueData = {
|
||||||
|
sender: { ...CONTACT_SENDER },
|
||||||
|
to: [{ email: CONTACT_RECIPIENT_EMAIL }],
|
||||||
|
htmlContent: `<html><head></head><body><div>Source: ${escapeHtml(subjectPrefix)}</div><div>Request Name: ${escapeHtml(name)}</div><div>Request Email: ${escapeHtml(email)}</div><div>Request Message: ${escapeHtml(message)}</div></body></html>`,
|
||||||
|
subject: buildContactSubject(subjectPrefix)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchWithRetry(
|
||||||
|
async () => {
|
||||||
|
const response = await fetchWithTimeout(apiUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"api-key": apiKey,
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(sendinblueData),
|
||||||
|
timeout: NETWORK_CONFIG.EMAIL_API_TIMEOUT_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
await checkResponse(response);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxRetries: NETWORK_CONFIG.MAX_RETRIES,
|
||||||
|
retryDelay: NETWORK_CONFIG.RETRY_DELAY_MS
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const exp = new Date(Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS);
|
||||||
|
setCookie("contactRequestSent", exp.toUTCString(), {
|
||||||
|
expires: exp,
|
||||||
|
path: "/"
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect("/contact?success=true");
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage =
|
||||||
|
"Failed to send message. You can reach me at michael@freno.me";
|
||||||
|
|
||||||
|
if (error instanceof TimeoutError) {
|
||||||
|
errorMessage =
|
||||||
|
"Email service timed out. Please try again or contact michael@freno.me";
|
||||||
|
} else if (error instanceof NetworkError) {
|
||||||
|
errorMessage =
|
||||||
|
"Network error. Please try again or contact michael@freno.me";
|
||||||
|
} else if (error instanceof APIError) {
|
||||||
|
errorMessage =
|
||||||
|
"Email service error. You can reach me at michael@freno.me";
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(`/contact?error=${encodeURIComponent(errorMessage)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ContactForm(props: ContactFormProps) {
|
||||||
|
const site = useSite();
|
||||||
|
const ctx = () => getContactContext(site().id);
|
||||||
|
|
||||||
|
// Effective values — props override the site-context defaults.
|
||||||
|
const effectiveSubjectPrefix = () =>
|
||||||
|
props.subjectPrefix ?? ctx().subjectPrefix;
|
||||||
|
const effectiveRecipientLabel = () =>
|
||||||
|
props.recipientLabel ?? ctx().recipientLabel;
|
||||||
|
const effectiveHeading = () => props.heading ?? ctx().heading;
|
||||||
|
const effectivePageTitle = () => props.pageTitle ?? ctx().pageTitle;
|
||||||
|
const effectivePageDescription = () =>
|
||||||
|
props.pageDescription ?? ctx().description;
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const contactData = createAsync(() => getContactData(), {
|
||||||
|
deferStream: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const [emailSent, setEmailSent] = createSignal<boolean>(
|
||||||
|
searchParams.success === "true"
|
||||||
|
);
|
||||||
|
const [error, setError] = createSignal<string>(
|
||||||
|
searchParams.error ? decodeURIComponent(String(searchParams.error)) : ""
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = createSignal<boolean>(false);
|
||||||
|
const [user, setUser] = createSignal<UserProfile | null>(null);
|
||||||
|
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
|
||||||
|
const [turnstileToken, setTurnstileToken] = createSignal<string>("");
|
||||||
|
const [turnstileWidgetId, setTurnstileWidgetId] = createSignal<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setJsEnabled(true);
|
||||||
|
|
||||||
|
// Load Cloudflare Turnstile script with explicit rendering.
|
||||||
|
// The site key is shared across all subdomains — ensure it is configured
|
||||||
|
// for `*.freno.me` in the Cloudflare Turnstile dashboard.
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => {
|
||||||
|
if (typeof window !== "undefined" && (window as any).turnstile) {
|
||||||
|
const container = document.getElementById("turnstile-widget-1");
|
||||||
|
if (container) {
|
||||||
|
const id = (window as any).turnstile.render(container, {
|
||||||
|
sitekey: clientEnv.VITE_TURNSTILE_SITE_KEY,
|
||||||
|
theme: "dark",
|
||||||
|
callback: (token: string) => {
|
||||||
|
setTurnstileToken(token);
|
||||||
|
},
|
||||||
|
"expired-callback": () => {
|
||||||
|
setTurnstileToken("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTurnstileWidgetId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
// Best-effort profile prefill. On subdomain sites there is no freno.me web
|
||||||
|
// auth (Nessa uses Clerk, Lineage uses its mobile JWT) so this resolves to
|
||||||
|
// null / 401 — the `.catch` swallows it and the fields stay blank.
|
||||||
|
api.user.getProfile
|
||||||
|
.query()
|
||||||
|
.then((userData) => {
|
||||||
|
if (userData) {
|
||||||
|
setUser(userData);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// Try server data first (more accurate)
|
||||||
|
const serverData = contactData();
|
||||||
|
if (serverData?.remainingTime && serverData.remainingTime > 0) {
|
||||||
|
const expirationTime = new Date(
|
||||||
|
Date.now() + serverData.remainingTime * 1000
|
||||||
|
);
|
||||||
|
startCountdown(expirationTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to client cookie if server data not available yet
|
||||||
|
const timer = getClientCookie("contactRequestSent");
|
||||||
|
if (timer) {
|
||||||
|
try {
|
||||||
|
startCountdown(timer);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to start countdown from cookie:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendEmailTrigger = async (e: Event) => {
|
||||||
|
if (!jsEnabled()) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target as unknown as HTMLFormElement;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
const name = formData.get("name") as string;
|
||||||
|
const email = formData.get("email") as string;
|
||||||
|
const message = formData.get("message") as string;
|
||||||
|
|
||||||
|
if (name && email && message) {
|
||||||
|
let currentToken = turnstileToken();
|
||||||
|
if (
|
||||||
|
!currentToken &&
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
(window as any).turnstile
|
||||||
|
) {
|
||||||
|
const widgetEl = document.getElementById("turnstile-widget-1");
|
||||||
|
if (widgetEl) {
|
||||||
|
const id = turnstileWidgetId();
|
||||||
|
currentToken = (window as any).turnstile.getResponse(id || widgetEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentToken || currentToken.trim() === "") {
|
||||||
|
setError("Please complete the security check.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setEmailSent(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.misc.sendContactRequest.mutate({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
message,
|
||||||
|
turnstileToken: currentToken,
|
||||||
|
subjectPrefix: effectiveSubjectPrefix()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.message === "email sent") {
|
||||||
|
setEmailSent(true);
|
||||||
|
setError("");
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
if (typeof window !== "undefined" && (window as any).turnstile) {
|
||||||
|
const widgetEl = document.getElementById("turnstile-widget-1");
|
||||||
|
if (widgetEl) {
|
||||||
|
const id = turnstileWidgetId();
|
||||||
|
(window as any).turnstile.reset(id || widgetEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTurnstileToken("");
|
||||||
|
|
||||||
|
// Set countdown directly — cookie might not be readable immediately
|
||||||
|
const expirationTime = new Date(
|
||||||
|
Date.now() + COOLDOWN_TIMERS.CONTACT_REQUEST_MS
|
||||||
|
);
|
||||||
|
startCountdown(expirationTime);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "An error occurred");
|
||||||
|
setEmailSent(false);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTime = ({ remainingTime }: { remainingTime: number }) => {
|
||||||
|
const time = isNaN(remainingTime) ? 0 : Math.max(0, remainingTime);
|
||||||
|
return (
|
||||||
|
<div class="timer">
|
||||||
|
<div class="value">{time.toFixed(0)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead
|
||||||
|
title={effectivePageTitle()}
|
||||||
|
description={effectivePageDescription()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="bg-base flex min-h-screen w-full justify-center">
|
||||||
|
<div class="w-full max-w-4xl px-4 pt-[20vh]">
|
||||||
|
<div class="text-center text-3xl tracking-widest">
|
||||||
|
{effectiveHeading()}
|
||||||
|
</div>
|
||||||
|
<Show when={props.subline}>
|
||||||
|
<div class="mt-4 -mb-4 text-center text-xl tracking-widest">
|
||||||
|
{props.subline}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
{props.children}
|
||||||
|
<form
|
||||||
|
onSubmit={sendEmailTrigger}
|
||||||
|
method="post"
|
||||||
|
action={sendContactEmail}
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
{/* Hidden per-site subject prefix — consumed by the no-JS action. */}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="subjectPrefix"
|
||||||
|
value={effectiveSubjectPrefix()}
|
||||||
|
/>
|
||||||
|
<div class="flex w-full flex-col justify-evenly">
|
||||||
|
<div class="mx-auto w-full justify-evenly md:flex md:flex-row">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
name="name"
|
||||||
|
value={user()?.displayName ?? ""}
|
||||||
|
title="Please enter your name"
|
||||||
|
label="Name"
|
||||||
|
containerClass="input-group md:mx-4"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
name="email"
|
||||||
|
value={user()?.email ?? ""}
|
||||||
|
title="Please enter a valid email address"
|
||||||
|
label="Email"
|
||||||
|
containerClass="input-group md:mx-4"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mx-auto w-full pt-6 md:pt-12">
|
||||||
|
<div class="textarea-group">
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
name="message"
|
||||||
|
placeholder=" "
|
||||||
|
title="Please enter your message"
|
||||||
|
class="underlinedInput w-full bg-transparent"
|
||||||
|
rows={4}
|
||||||
|
maxlength={VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH}
|
||||||
|
/>
|
||||||
|
<span class="bar" />
|
||||||
|
<label class="underlinedInputLabel">Message</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mx-auto flex w-full justify-between pt-4">
|
||||||
|
<div id="turnstile-widget-1"></div>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
remainingTime() > 0 ||
|
||||||
|
(contactData()?.remainingTime ?? 0) > 0
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<Button type="submit" loading={loading()} class="w-36">
|
||||||
|
Send Message
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={jsEnabled()}
|
||||||
|
fallback={
|
||||||
|
<div class="flex items-center justify-center text-sm text-zinc-400">
|
||||||
|
Please wait{" "}
|
||||||
|
{Math.ceil(contactData()?.remainingTime ?? 0)}s before
|
||||||
|
sending another message
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CountdownCircleTimer
|
||||||
|
duration={COUNTDOWN_CONFIG.CONTACT_FORM_DURATION_S}
|
||||||
|
initialRemainingTime={remainingTime()}
|
||||||
|
size={48}
|
||||||
|
strokeWidth={6}
|
||||||
|
onComplete={() => setRemainingTime(0)}
|
||||||
|
>
|
||||||
|
{renderTime}
|
||||||
|
</CountdownCircleTimer>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div
|
||||||
|
class={`${
|
||||||
|
emailSent()
|
||||||
|
? "text-green-400"
|
||||||
|
: error() !== ""
|
||||||
|
? "text-red-400"
|
||||||
|
: "user-select opacity-0"
|
||||||
|
} flex justify-center text-center italic transition-opacity duration-300 ease-in-out`}
|
||||||
|
>
|
||||||
|
{emailSent()
|
||||||
|
? `Email sent to ${effectiveRecipientLabel()}!`
|
||||||
|
: error()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContactForm;
|
||||||
@@ -4,7 +4,10 @@ import SunIcon from "./icons/SunIcon";
|
|||||||
import { Typewriter } from "./Typewriter";
|
import { Typewriter } from "./Typewriter";
|
||||||
import { useDarkMode } from "~/context/darkMode";
|
import { useDarkMode } from "~/context/darkMode";
|
||||||
|
|
||||||
export function DarkModeToggle() {
|
export function DarkModeToggle(
|
||||||
|
props: { shouldScale?: boolean } = { shouldScale: true }
|
||||||
|
) {
|
||||||
|
const shouldScale = props.shouldScale ?? true;
|
||||||
const { isDark, toggleDarkMode } = useDarkMode();
|
const { isDark, toggleDarkMode } = useDarkMode();
|
||||||
const [mounted, setMounted] = createSignal(false);
|
const [mounted, setMounted] = createSignal(false);
|
||||||
|
|
||||||
@@ -15,7 +18,7 @@ export function DarkModeToggle() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleDarkMode}
|
onClick={toggleDarkMode}
|
||||||
class="hover:bg-surface0 flex w-full items-center gap-3 rounded-lg p-3 transition-all duration-200 ease-in-out hover:scale-105"
|
class={`hover:bg-surface0 flex w-full items-center gap-3 rounded-lg p-3 transition-all duration-200 ease-in-out ${shouldScale ? "hover:scale-105" : ""}`}
|
||||||
aria-label="Toggle dark mode"
|
aria-label="Toggle dark mode"
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -3,7 +3,32 @@ import CountdownCircleTimer from "~/components/CountdownCircleTimer";
|
|||||||
import { Spinner } from "~/components/Spinner";
|
import { Spinner } from "~/components/Spinner";
|
||||||
import { getClientCookie } from "~/lib/cookies.client";
|
import { getClientCookie } from "~/lib/cookies.client";
|
||||||
|
|
||||||
export default function DeletionForm() {
|
/**
|
||||||
|
* Product discriminator forwarded to the generalized
|
||||||
|
* `misc.sendDeletionRequestEmail` mutation so the email copy + cooldown
|
||||||
|
* cookie are product-appropriate.
|
||||||
|
*/
|
||||||
|
export type DeletionProduct = "lineage" | "nessa";
|
||||||
|
|
||||||
|
export interface DeletionFormProps {
|
||||||
|
/**
|
||||||
|
* Product whose account is being deleted. Determines the email branding
|
||||||
|
* AND the cooldown cookie name on the server. Defaults to `"lineage"`
|
||||||
|
* (the original / legacy flow) for backward compatibility.
|
||||||
|
*/
|
||||||
|
product?: DeletionProduct;
|
||||||
|
/**
|
||||||
|
* Cooldown cookie name read on mount + written by the server response
|
||||||
|
* (the mutation sets its own cookie; this is only for the client-side
|
||||||
|
* countdown). Defaults to the legacy `deletionRequestSent` name so an
|
||||||
|
* in-flight Lineage cooldown survives the legacy redirect.
|
||||||
|
*/
|
||||||
|
cookieName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeletionForm(props: DeletionFormProps = {}) {
|
||||||
|
const product = () => props.product ?? "lineage";
|
||||||
|
const cookieName = () => props.cookieName ?? "deletionRequestSent";
|
||||||
const [countDown, setCountDown] = createSignal(0);
|
const [countDown, setCountDown] = createSignal(0);
|
||||||
const [emailSent, setEmailSent] = createSignal(false);
|
const [emailSent, setEmailSent] = createSignal(false);
|
||||||
const [error, setError] = createSignal("");
|
const [error, setError] = createSignal("");
|
||||||
@@ -28,7 +53,7 @@ export default function DeletionForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const timer = getClientCookie("deletionRequestSent");
|
const timer = getClientCookie(cookieName());
|
||||||
if (timer) {
|
if (timer) {
|
||||||
timerInterval = setInterval(
|
timerInterval = setInterval(
|
||||||
() => calcRemainder(timer),
|
() => calcRemainder(timer),
|
||||||
@@ -60,14 +85,14 @@ export default function DeletionForm() {
|
|||||||
const response = await fetch("/api/trpc/misc.sendDeletionRequestEmail", {
|
const response = await fetch("/api/trpc/misc.sendDeletionRequestEmail", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email })
|
body: JSON.stringify({ email, product: product() })
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.result?.data?.message === "request sent") {
|
if (response.ok && result.result?.data?.message === "request sent") {
|
||||||
setEmailSent(true);
|
setEmailSent(true);
|
||||||
const timer = getClientCookie("deletionRequestSent");
|
const timer = getClientCookie(cookieName());
|
||||||
if (timer) {
|
if (timer) {
|
||||||
if (timerInterval) {
|
if (timerInterval) {
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
|
|||||||
29
src/components/EdgeCacheHeaders.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { HttpHeader } from "@solidjs/start";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders edge-cache response headers for a page route.
|
||||||
|
*
|
||||||
|
* Sets `CDN-Cache-Control` so Vercel serves the rendered HTML from the edge
|
||||||
|
* (no origin re-render) for `maxAge` seconds, then stale-while-revalidate up
|
||||||
|
* to `staleSeconds`. `Cache-Control: public, max-age=0` keeps browsers
|
||||||
|
* revalidating, so visitors always get the latest version — only the CDN
|
||||||
|
* holds a cached copy. Function/CND-Cache-Control overrides the Vercel
|
||||||
|
* default, so repeated visits and bot crawls stop re-rendering at origin.
|
||||||
|
*/
|
||||||
|
export function EdgeCacheHeaders(props: {
|
||||||
|
/** Seconds the CDN may serve the response fresh. */
|
||||||
|
maxAge: number;
|
||||||
|
/** Seconds the CDN serves stale while revalidating (default: 1 day). */
|
||||||
|
staleSeconds?: number;
|
||||||
|
}) {
|
||||||
|
const stale = props.staleSeconds ?? 86400;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HttpHeader name="Cache-Control" value="public, max-age=0" />
|
||||||
|
<HttpHeader
|
||||||
|
name="CDN-Cache-Control"
|
||||||
|
value={`public, s-maxage=${props.maxAge}, stale-while-revalidate=${stale}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
src/components/PageHead.test.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for `PageHead` site-aware metadata derivation.
|
||||||
|
*
|
||||||
|
* `resolvePageHeadMeta` is a pure function over (props, site, pathname), so
|
||||||
|
* these tests mirror the acceptance matrix without a DOM / SolidJS router.
|
||||||
|
* The render layer (`PageHead` component) is a thin wrapper over this function.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import {
|
||||||
|
resolvePageHeadMeta,
|
||||||
|
type PageHeadProps
|
||||||
|
} from "~/components/page-head-meta";
|
||||||
|
import { SITE_CONFIG, type SiteId } from "~/lib/site-context";
|
||||||
|
|
||||||
|
const BASE_PROPS: PageHeadProps = {
|
||||||
|
title: "Blog",
|
||||||
|
description: "Technical blog posts about web development."
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("resolvePageHeadMeta — title suffix per site", () => {
|
||||||
|
const cases: Array<{ id: SiteId; suffix: string }> = [
|
||||||
|
{ id: "main", suffix: " | Michael Freno" },
|
||||||
|
{ id: "nessa", suffix: " | Nessa" },
|
||||||
|
{ id: "lineage", suffix: " | Life and Lineage" },
|
||||||
|
{ id: "gaze", suffix: " | Gaze" },
|
||||||
|
{ id: "inputhalo", suffix: " | InputHalo" }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { id, suffix } of cases) {
|
||||||
|
it(`${id} → title is "${BASE_PROPS.title}${suffix}"`, () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG[id], "/blog");
|
||||||
|
expect(meta.title).toBe(`${BASE_PROPS.title}${suffix}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("main produces 'Home | Michael Freno' for the homepage", () => {
|
||||||
|
const meta = resolvePageHeadMeta({ title: "Home" }, SITE_CONFIG.main, "/");
|
||||||
|
expect(meta.title).toBe("Home | Michael Freno");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolvePageHeadMeta — canonical URL derivation", () => {
|
||||||
|
it("main → canonical starts with https://freno.me", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
||||||
|
expect(meta.canonical).toBe("https://freno.me/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("main blog → https://freno.me/blog", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/blog");
|
||||||
|
expect(meta.canonical).toBe("https://freno.me/blog");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nessa → canonical starts with https://nessa.freno.me", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/");
|
||||||
|
expect(meta.canonical).toBe("https://nessa.freno.me/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nessa /contact → https://nessa.freno.me/contact", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/contact");
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gaze → canonical starts with https://gaze.freno.me", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.gaze, "/");
|
||||||
|
expect(meta.canonical.startsWith("https://gaze.freno.me")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inputhalo → canonical starts with https://inputhalo.freno.me", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.inputhalo, "/");
|
||||||
|
expect(meta.canonical.startsWith("https://inputhalo.freno.me")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the full pathname including nested segments + search is NOT included", () => {
|
||||||
|
// useLocation().pathname excludes the query string; canonical should too.
|
||||||
|
const meta = resolvePageHeadMeta(
|
||||||
|
BASE_PROPS,
|
||||||
|
SITE_CONFIG.main,
|
||||||
|
"/blog/my-post"
|
||||||
|
);
|
||||||
|
expect(meta.canonical).toBe("https://freno.me/blog/my-post");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit `canonical` prop overrides auto-derivation", () => {
|
||||||
|
const meta = resolvePageHeadMeta(
|
||||||
|
{ ...BASE_PROPS, canonical: "https://example.com/override" },
|
||||||
|
SITE_CONFIG.nessa,
|
||||||
|
"/contact"
|
||||||
|
);
|
||||||
|
expect(meta.canonical).toBe("https://example.com/override");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolvePageHeadMeta — OpenGraph fallbacks", () => {
|
||||||
|
it("ogImage defaults to the site's ogDefaultImage, resolved to an absolute URL", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/");
|
||||||
|
expect(meta.ogImage).toBe("https://nessa.freno.me/nessa/og-default.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the nook default resolves to the absolute og.png URL", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nook, "/");
|
||||||
|
expect(meta.ogImage).toBe("https://nook.freno.me/nook/og.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit absolute ogImage passes through unchanged", () => {
|
||||||
|
const meta = resolvePageHeadMeta(
|
||||||
|
{ ...BASE_PROPS, ogImage: "https://cdn/custom.png" },
|
||||||
|
SITE_CONFIG.main,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit ogTitle overrides the title fallback", () => {
|
||||||
|
const meta = resolvePageHeadMeta(
|
||||||
|
{ ...BASE_PROPS, ogTitle: "Custom OG Title" },
|
||||||
|
SITE_CONFIG.main,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
|
expect(meta.ogTitle).toBe("Custom OG Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ogDescription falls back to description", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
||||||
|
expect(meta.ogDescription).toBe(BASE_PROPS.description);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit ogDescription overrides the description fallback", () => {
|
||||||
|
const meta = resolvePageHeadMeta(
|
||||||
|
{ ...BASE_PROPS, ogDescription: "Custom OG desc" },
|
||||||
|
SITE_CONFIG.main,
|
||||||
|
"/"
|
||||||
|
);
|
||||||
|
expect(meta.ogDescription).toBe("Custom OG desc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("description is passed through unchanged", () => {
|
||||||
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.main, "/");
|
||||||
|
expect(meta.description).toBe(BASE_PROPS.description);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,31 @@
|
|||||||
import { Title, Meta, Link } from "@solidjs/meta";
|
import { Title, Meta, Link } from "@solidjs/meta";
|
||||||
|
import { useLocation } from "@solidjs/router";
|
||||||
|
import { useSite } from "~/context/SiteContext";
|
||||||
|
import {
|
||||||
|
resolvePageHeadMeta,
|
||||||
|
type PageHeadProps
|
||||||
|
} from "~/components/page-head-meta";
|
||||||
|
|
||||||
export interface PageHeadProps {
|
// Re-export the pure types + resolver so existing imports
|
||||||
title: string;
|
// (`import { PageHead } from "~/components/PageHead"`) plus any consumer that
|
||||||
description?: string;
|
// wants the meta helper resolve from a single module path.
|
||||||
ogImage?: string;
|
export {
|
||||||
ogTitle?: string;
|
resolvePageHeadMeta,
|
||||||
ogDescription?: string;
|
type PageHeadProps,
|
||||||
canonical?: string;
|
type ResolvedPageHeadMeta
|
||||||
}
|
} from "~/components/page-head-meta";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PageHead component for consistent page metadata across the application.
|
* PageHead component for consistent page metadata across the application.
|
||||||
* Automatically appends " | Michael Freno" to the title.
|
*
|
||||||
|
* Site-aware: reads `useSite()` for the per-site title suffix,
|
||||||
|
* canonical domain, and default OpenGraph image, so the same component
|
||||||
|
* renders `" | Michael Freno"` / `" | Nessa"` / … depending on the active
|
||||||
|
* subdomain. Canonical URLs are auto-derived from the site domain + the
|
||||||
|
* current router pathname unless an explicit `canonical` override is given.
|
||||||
|
*
|
||||||
|
* The actual derivation lives in the pure `resolvePageHeadMeta` helper (see
|
||||||
|
* `~/components/page-head-meta.ts`) so it can be unit-tested without a DOM.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
@@ -23,27 +37,27 @@ export interface PageHeadProps {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export default function PageHead(props: PageHeadProps) {
|
export default function PageHead(props: PageHeadProps) {
|
||||||
const fullTitle = () => `${props.title} | Michael Freno`;
|
const site = useSite();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const meta = () => resolvePageHeadMeta(props, site(), location.pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>{fullTitle()}</Title>
|
<Title>{meta().title}</Title>
|
||||||
{props.description && (
|
{meta().description && (
|
||||||
<Meta name="description" content={props.description} />
|
<Meta name="description" content={meta().description} />
|
||||||
)}
|
)}
|
||||||
{props.canonical && <Link rel="canonical" href={props.canonical} />}
|
<Link rel="canonical" href={meta().canonical} />
|
||||||
|
|
||||||
{/* Open Graph / Social Media Tags */}
|
{/* Open Graph / Social Media Tags */}
|
||||||
{(props.ogTitle || props.title) && (
|
<Meta property="og:title" content={meta().ogTitle} />
|
||||||
<Meta property="og:title" content={props.ogTitle || props.title} />
|
{meta().ogDescription && (
|
||||||
|
<Meta property="og:description" content={meta().ogDescription} />
|
||||||
)}
|
)}
|
||||||
{(props.ogDescription || props.description) && (
|
<Meta property="og:image" content={meta().ogImage} />
|
||||||
<Meta
|
{/* X/Twitter large-image card; falls back to the og:* tags above. */}
|
||||||
property="og:description"
|
<Meta name="twitter:card" content="summary_large_image" />
|
||||||
content={props.ogDescription || props.description}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{props.ogImage && <Meta property="og:image" content={props.ogImage} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||