diff --git a/README.md b/README.md index 98970a07..a8c21711 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,10 @@ npx timetm init | Claude Code hooks | Save before each prompt and before risky shell commands (`rm -rf`, `git reset --hard`, `git clean -f`, `DROP TABLE` and similar). Check at the end of each turn, and tell Claude if a working state just broke | Built, tested | | MCP server | 7 tools for any agent that speaks MCP: checkpoint, timeline, diff, verify, find_good, recover, log | Built, tested | | Store | Save points live in `~/.timemachine` (or `$TM_HOME`) with a git bundle, so they survive the project folder being deleted | Built, tested | -| Tests | 228 automated tests, passing in CI on macOS and Linux with Node 22 and 24 (2 run only on Linux). Also run on a real project, `sindresorhus/slugify` | Built | +| Tests | 250 automated tests, passing in CI on macOS and Linux with Node 22 and 24 (2 run only on Linux). Also run on a real project, `sindresorhus/slugify` | Built | | npm package | `timetm`, command `tm` | Not published yet | | Mac app | Notebook history, Rewind dial with undo, Compare, agent channels, overlays, opt-in Ask and read-only Vercel deploy links. Native macOS smoke exercised real-engine recovery and the non-focusing corner card | Built from source; not signed or shipped | -| Website | Static site in `site/` with a waitlist | In development, private | +| Website | Static site in `site/` with a waitlist, personal referral links and anonymous funnel counts. Public at https://time-machine-site.vercel.app | Live; waitlist saves once Supabase is set up | | Action connector | Record and reverse, where honest, what agents do in Gmail, Slack, Notion, Calendar and Stripe through Claude and ChatGPT | Planned (v2), see ADR 0007 | | Pro pricing | "The Ladder": $9 a month, $8 from month 7, $7 from month 13, $6 floor. The CLI is free. Restores are never paywalled | Planned, see ADR 0002 | @@ -139,13 +139,9 @@ Then open http://localhost:8080. The waitlist form needs the Vercel function, so ## Waitlist setup (for the founder) -The form posts to `/api/waitlist`, which writes to one Supabase table. Until both settings exist, the form says the waitlist is not open yet and asks people to email the founder. It never fakes a signup. +The form posts to `/api/waitlist`, which writes to Supabase. Until the settings exist, the form says the waitlist opens soon and asks people to email the founder. It never fakes a signup. -1. **Create a Supabase project** for Time Machine. -2. **Create the table.** In its SQL editor, run all of `supabase/migrations/0001_waitlist.sql`. -3. **Add two settings in Vercel** for the project `time-machine-site`: `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY`. Then redeploy. - -Check it at `/api/health`, which should say `"waitlist":true`. Only the email, the consent tick, the signup page and the date are stored. No IP address. Full steps, export and delete: `supabase/README.md`. The decision: `docs/adr/0006-waitlist-backend.md`. +Step by step, with the SQL to paste and the queries to read signups and the funnel: `docs/launch/SUPABASE_SETUP.md`. Check it at `/api/health`, which should say `"waitlist":true`. What is stored: `site/privacy.html` and `supabase/README.md`. The decision: `docs/adr/0006-waitlist-backend.md`. ## Run the tests diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 0ec3474e..eb400de8 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -83,7 +83,7 @@ Read this file first, then `CLAUDE.md`, then `docs/BUILD_BRIEF.md`. This file is ## 4. Repo map - **Repo:** https://github.com/ShadowEsu/Time-Machine (public). Local clone: `~/projects/Time-Machine`. -- **Branch:** `claude/awesome-mendel-5sk1w0`. Never merged to `main`. +- **Branches:** `main` (merged from `claude/awesome-mendel-5sk1w0` by PR #1 on 17 Oct, history kept). Keep working on a branch and merge by PR after CI is green. - **Package manager:** pnpm 10.28.0 via corepack. Node 22+ (the Mac has Node 25). | Path | What | @@ -170,12 +170,14 @@ Throwaway Playwright scripts (not in the repo) live in `/tmp/tm-pw/` (m7v3.mjs c - CI green on macOS and Linux, Node 22 and 24: run 39070976942 on commit 1a1d322. - Private Vercel preview redeployed from `git archive HEAD`: https://time-machine-site-5ku15uzil-shadowesus-projects.vercel.app (anonymous 302; deployed `index.html` contains opening.js, sound.js, "Play break + rewind", "passes your tests", "228"). Older preview: https://time-machine-site-8ka8ecc0l-shadowesus-projects.vercel.app. -### Waitlist share step (17 Oct evening, private only) -- After a real signup (server said ok), the form shows one line and one button: "Know someone who lets an AI agent change their code? Send them the link." Uses the Web Share API, else copies `/?via=share`, else shows the link in a read-only field. No rewards, no queue position, no counters (EXPERIENCE §8). -- A visit that starts at `?via=share` is kept in `sessionStorage` (`tm-via`), so a signup on any page stores `source` like `/how-it-works.html?via=share`. Server unchanged (`cleanSource` already accepts it). Privacy draft says so and says we do not record who shared. -- Measure once the backend is live: share-driven signups = rows whose `source` ends in `?via=share`, divided by all rows. -- Verified: `/tmp/tm-pw/share-smoke.mjs` (1440 and 390, mocked `/api/waitlist`): source carries `?via=share` across pages, plain source without it, Copy link and manual-field fallback work, one block after two signups, no page errors. Shots `/tmp/tm-pw/share-1440.png`, `share-390.png`. Site waitlist tests 14/14. -- Needs Preston's OK on the copy before any public launch (human gate). +### Public site, waitlist growth, MIT (17 Oct evening, founder decisions relayed by BOSS) +- **Public:** Vercel SSO protection turned off on `time-machine-site` (API `PATCH ssoProtection: null`); anonymous GET https://time-machine-site.vercel.app/ returns 200. Production deploys are authorized after screenshot self-review and green CI. +- **License MIT** (ADR 0010, `LICENSE`, every package.json). npm still unpublished. +- **Home page rebuilt** (Preston: "the website is horrible"): one promise ("Your AI agent broke it. Get back the version that passed your tests."), real `tm demo` output right beside or under it, the waitlist form in the first screen, then Why (Claude Code docs quote), How (3 steps + what does not come back), Built in the open (CI, MIT, agents), 6 FAQs, end form. 4014 px tall at 1440 (was 8460), 4845 at 390 (was 11332). The first-visit opening, the 3D device, sound and the Ladder were removed from home (in git history; the story page keeps its own cinema). Absolute OG image `assets/img/og.png` on every page, canonical, `robots.txt`, `sitemap.xml`. +- **Referral + funnel** (contract in `docs/launch/SUPABASE_SETUP.md` and `site/api/_lib/`): signup returns a personal code, place in line and total; `?ref=` is kept per visit and credited on signup; returning visitors see their real place (`GET /api/waitlist?code=`). Order rule shown on the site: most friends joined first, then earliest signup. `/api/event` counts visit, copy and share with no IP, cookie or identifier, and skips browsers with GPC or DNT. View `funnel_daily` reads the funnel. The no-checkbox hero form asks consent through the button and the line under it; forms with a checkbox still need the tick. +- **Honest closed state:** `/api/health` says `waitlist:false`, so every form says up front "The waitlist opens soon. It is not saving emails yet." with the founder's email. +- **Verified:** `pnpm typecheck`, `pnpm test` 248 passed + 2 Linux-only skipped. `/tmp/tm-pw/audit5.mjs`: 108 loads (9 pages x 1440/390/320 x light/dark x motion/reduced) with no page errors and no sideways scroll. `/tmp/tm-pw/ref-smoke.mjs` (mocked API at 1440/390/320): ref credited across pages, card shows "#3 of 41", reload shows fresh "#2 of 42 / 1 friend", Copy puts `/?ref=` on the clipboard, events carry `via: ref`. Mobile LCP (390, 4x CPU, slow 4G, local server) 0.90 to 0.92 s, element `p.dek`. Shots: `/tmp/tm-pw/v4-*.png`, `ref-*.png`, `soon-390.png`. Backend SQL ran twice on a local Postgres 17 with Supabase-style roles (WaitlistBackend agent). +- **Not done:** double opt-in and unsubscribe emails (need an email provider and a sending domain; today nothing is emailed); real Supabase; Safari/Firefox/physical phones. --- @@ -188,10 +190,10 @@ Throwaway Playwright scripts (not in the repo) live in `/tmp/tm-pw/` (m7v3.mjs c 4. Keep test counts and status in `README.md`, `docs/PLAN.md`, `site/index.html`, `site/story.html`, `site/company.html` in sync. ### Blocked on the founder -7. **npm publish of `timetm` 0.1.0.** Package is ready (`npm pack --dry-run`: 3 files, 151 kB; installs from tarball; `tm --help` works). The name was unclaimed as of 16 Oct. Needs: founder npm account with 2FA, `npm login`, a license choice recorded as an ADR and a LICENSE file. Until published, the app uses its bundled CLI and the pinned npx fallback. +7. **npm publish of `timetm` 0.1.0.** Package is ready (`npm pack --dry-run`: 3 files, 151 kB; installs from tarball; `tm --help` works). License is MIT (ADR 0010). Needs: founder npm account with 2FA and `npm login`, then remove `"private": true` from `packages/cli/package.json` and publish. Until published, the app uses its bundled CLI and the pinned npx fallback. 8. **D08 signing and notarization** of the Mac app: needs an Apple Developer account and an adult signer (founder is a student; see `docs/legal/README.md`). -9. **Waitlist backend:** Supabase project plus two Vercel env settings (ADR 0006). Until then the form answers "not configured". -10. **Turn off Vercel login protection** only when the founder approves the copy. +9. **Waitlist backend:** the founder creates the Supabase project by following `docs/launch/SUPABASE_SETUP.md`, then a redeploy. Until then the form says the waitlist opens soon. +10. **Vercel login protection:** off since 17 Oct (founder decision). The site is public on vercel.app. 11. **Domain** purchase, **lawyer review** of Terms and Privacy, **who signs for money**. 12. **Record the 60 second demo video** (`docs/DEMO_SCRIPT.md`). 13. **Fill `[FOUNDER]` placeholders** and submit: a16z speedrun by 1 Nov 11:59pm PT, YC W27 by 2 Nov 8pm PT. Notes from the drafts: a16z FAQ suggests students check the alpha fellowship and email sr-team@a16z.com about age; speedrun residency is in person in SF from Feb 2027. diff --git a/docs/PLAN.md b/docs/PLAN.md index f5ea71bf..cd56a46d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,8 +1,8 @@ # Plan to MVP -Status (17 October 2026): M0 to M3 code is built; npm publication still waits on the founder's account and license. 228 automated tests pass in CI on macOS and Linux, Node 22 and 24 (run 39070976942; 2 tests run only on Linux). The founder chose Notebook for the Mac app, with the Instrument dial and agent channels, and Editorial for the website. D01 to D07 are built, including opt-in Ask about history (Gemini metadata only, ADR 0008) and read-only Vercel deployment links matched by recorded Git HEAD (ADR 0009). Native Electron smoke verified the demo, PASS, recovery and undo, bundled hooks, font loading and a non-focusing overlay. A live Ask request returned a cited answer after consent. The website has the cinematic opening with the giant wordmark, orange controls, opt-in sound and a break and rewind demo, and is redeployed as a private Vercel preview. Signing and shipping (D08), real Vercel credentials and end-to-end agent-session validation remain. The waitlist backend is built (ADR 0006) and reports not configured until the founder supplies Supabase settings. Demo, a16z SR008, YC W27 and outreach drafts are ready for founder facts. The action connector remains proposed v2 (ADR 0007). Full session memory: `docs/HANDOFF.md`. +Status (17 October 2026, evening): M0 to M3 code is built and merged to `main` (PR #1). License: MIT (ADR 0010). npm publication waits on the founder's account. 250 automated tests (248 on macOS, 2 run only on Linux) in CI on macOS and Linux, Node 22 and 24. D01 to D07 of the Mac app are built, including opt-in Ask (ADR 0008) and read-only Vercel deploy links (ADR 0009); native Electron smoke passed; signing (D08) remains. The website is public at https://time-machine-site.vercel.app: a short home page with one promise, real `tm demo` output as proof, one waitlist action, a personal referral link with the real place in line, and anonymous funnel counts. The cinematic story stays on `story.html`. The waitlist saves nothing until the founder creates the Supabase project (`docs/launch/SUPABASE_SETUP.md`). The action connector remains proposed v2 (ADR 0007). Full session memory: `docs/HANDOFF.md`. -Waiting on the founder: npm account and license (publish), a Supabase project and two Vercel settings for the waitlist (ADR 0006), who signs for money, lawyer review, and turning off Vercel login protection when the copy is approved. Anything not marked done is planned. Scope comes from `docs/BUILD_BRIEF.md`. This file says the order and the dates. +Waiting on the founder: npm account (publish), the Supabase project and two Vercel settings for the waitlist, who signs for money, lawyer review, a domain, and the demo video. Anything not marked done is planned. Scope comes from `docs/BUILD_BRIEF.md`. This file says the order and the dates. ## Goal diff --git a/docs/launch/SUPABASE_SETUP.md b/docs/launch/SUPABASE_SETUP.md new file mode 100644 index 00000000..05263311 --- /dev/null +++ b/docs/launch/SUPABASE_SETUP.md @@ -0,0 +1,314 @@ +# Supabase setup for the waitlist + +This guide is for Preston. It takes about 15 minutes. You do not need to write any code. + +When you finish, the waitlist on https://time-machine-site.vercel.app saves signups, gives each person a referral link, and counts page visits and shares. Until then, the form says the waitlist is not open yet and asks people to email you. It never pretends to save. + +## What gets stored + +- **Each signup:** the email, the consent tick, the page they signed up from, the time, their own referral code, and the referral code that brought them (if any). +- **Funnel counts:** the event type (visit, share or copy), the page path, whether the visit came from a referral link, and the time. +- **Never stored:** IP addresses, browser details, cookies. The funnel counts hold no email and no code. + +Only the site's server function can read or write any of this. The public key can do nothing. + +## 1. Create the Supabase project + +1. Go to https://supabase.com and sign in. +2. Click **New project**. +3. Pick your organization. Choose the **Free** plan. +4. Name it `time-machine`. +5. Make up a strong database password and save it in your password manager. You will not need it for this guide. +6. For **Region**, pick a West US option (for example West US, North California). +7. Click **Create new project**. Wait until it says the project is ready. This takes a minute or two. + +## 2. Create the tables + +1. In the left sidebar, click **SQL Editor**. +2. Click **New query**. +3. Copy the whole SQL block below, from the first line to the last. Paste it into the editor. +4. Click **Run**. +5. You should see "Success. No rows returned". Notices that say "already exists, skipping" are fine. +6. In the left sidebar, click **Table Editor**. You should see two tables: `waitlist` and `funnel_events`. Both are empty. + +The block is safe to run more than once. Running it again changes nothing. + +It is the two files `supabase/migrations/0001_waitlist.sql` and `supabase/migrations/0002_referrals_and_funnel.sql`, one after the other. + +```sql +-- Time Machine waitlist. +-- Stores an email, the consent tick, and the page the person signed up from. +-- No IP addresses, no user agents, nothing else. + +create table if not exists public.waitlist ( + id uuid primary key default gen_random_uuid(), + email text not null check (char_length(email) between 3 and 254), + email_normalized text generated always as (lower(btrim(email))) stored, + consent boolean not null check (consent), + source text check (source is null or char_length(source) <= 200), + created_at timestamptz not null default now(), + constraint waitlist_email_normalized_key unique (email_normalized) +); + +comment on table public.waitlist is + 'Waitlist signups. Written only by the site API with the service role key.'; + +-- Row level security on, and no policies at all. +-- The anon and authenticated roles can do nothing with this table. +-- The service role bypasses row level security and is the only writer. +alter table public.waitlist enable row level security; + +revoke all on table public.waitlist from anon, authenticated; +grant select, insert on table public.waitlist to service_role; + +-- Time Machine waitlist: referral codes, list position, and anonymous funnel counts. +-- Needs 0001_waitlist.sql first. Safe to run twice. +-- Adds to each signup: their own referral code, and the code (if any) that brought them. +-- Adds a funnel_events table that holds only an event type, a page path, a via-referral flag and the time. +-- No IP addresses, no user agents, no cookies, no emails or codes in funnel_events. + +-- A fresh code: 10 characters from a to z and 0 to 9, from random bytes. +create or replace function public.waitlist_new_code() +returns text +language sql +volatile +set search_path = '' +as $$ + select string_agg( + substr('abcdefghijklmnopqrstuvwxyz0123456789', 1 + get_byte(uuid_send(gen_random_uuid()), 0) % 36, 1), + '' + ) + from generate_series(1, 10); +$$; + +-- Existing rows get a code when the column is added. On a second run the whole line is skipped. +alter table public.waitlist + add column if not exists ref_code text not null default public.waitlist_new_code() + constraint waitlist_ref_code_key unique + constraint waitlist_ref_code_format check (ref_code ~ '^[a-z0-9]{10}$'); + +-- The code that brought this person, or null. Deleting the referrer clears it. +alter table public.waitlist + add column if not exists referred_by text + constraint waitlist_referred_by_fkey references public.waitlist (ref_code) on delete set null; + +create index if not exists waitlist_referred_by_idx on public.waitlist (referred_by); + +comment on column public.waitlist.ref_code is 'This person''s own referral code, made in SQL.'; +comment on column public.waitlist.referred_by is 'The referral code that brought this person, or null.'; + +-- Place in the list for one code. +-- Order rule, shown on the site: more friends who joined with your link first, then earlier join time first. +-- Position is 1 plus the number of entries ahead under that rule. No row back means the code is unknown. +create or replace function public.waitlist_status(p_code text) +returns table ("position" bigint, referrals bigint, total bigint) +language sql +stable +set search_path = '' +as $$ + with refs as ( + select w.referred_by as code, count(*) as n + from public.waitlist w + where w.referred_by is not null + group by w.referred_by + ), + entries as ( + select w.ref_code, w.created_at, coalesce(r.n, 0) as referrals + from public.waitlist w + left join refs r on r.code = w.ref_code + ) + select + 1 + ( + select count(*) + from entries e + where e.referrals > me.referrals + or (e.referrals = me.referrals and e.created_at < me.created_at) + ), + me.referrals, + (select count(*) from public.waitlist) + from entries me + where me.ref_code = p_code; +$$; + +-- Join the list. Returns one row (code, position, total) for a new signup. +-- Returns no rows when the email is already on the list, and changes nothing. +-- A ref that is badly shaped or matches no one is ignored. Your own code cannot match, +-- because your code does not exist until your row is added. +create or replace function public.waitlist_join(p_email text, p_source text default null, p_ref text default null) +returns table (code text, "position" bigint, total bigint) +language plpgsql +volatile +set search_path = '' +as $$ +declare + v_ref text; + v_code text; +begin + select w.ref_code into v_ref + from public.waitlist w + where p_ref ~ '^[a-z0-9]{10}$' and w.ref_code = p_ref; + + insert into public.waitlist (email, consent, source, referred_by) + values (p_email, true, p_source, v_ref) + on conflict (email_normalized) do nothing + returning ref_code into v_code; + + if v_code is null then + return; + end if; + + return query + select v_code, s."position", s.total + from public.waitlist_status(v_code) s; +end; +$$; + +-- Anonymous funnel counts. +create table if not exists public.funnel_events ( + id uuid primary key default gen_random_uuid(), + type text not null check (type in ('visit', 'share', 'copy')), + path text not null check (char_length(path) between 1 and 200 and left(path, 1) = '/'), + via_ref boolean not null default false, + created_at timestamptz not null default now() +); + +comment on table public.funnel_events is + 'Anonymous page visits, shares and link copies. Written only by the site API with the service role key.'; + +-- Signups and funnel counts per day (UTC). +create or replace view public.funnel_daily +with (security_invoker = true) +as +with e as ( + select + (f.created_at at time zone 'utc')::date as day, + count(*) filter (where f.type = 'visit') as visits, + count(*) filter (where f.type = 'visit' and f.via_ref) as visits_via_ref, + count(*) filter (where f.type = 'share') as shares, + count(*) filter (where f.type = 'copy') as copies + from public.funnel_events f + group by 1 +), +s as ( + select + (w.created_at at time zone 'utc')::date as day, + count(*) as signups, + count(*) filter (where w.referred_by is not null) as signups_via_ref + from public.waitlist w + group by 1 +) +select + coalesce(e.day, s.day) as day, + coalesce(e.visits, 0) as visits, + coalesce(e.visits_via_ref, 0) as visits_via_ref, + coalesce(s.signups, 0) as signups, + coalesce(s.signups_via_ref, 0) as signups_via_ref, + coalesce(e.shares, 0) as shares, + coalesce(e.copies, 0) as copies +from e +full join s on s.day = e.day +order by 1 desc; + +-- Row level security on, and no policies at all. Only the service role can read or write. +alter table public.funnel_events enable row level security; + +revoke all on table public.funnel_events from public, anon, authenticated; +revoke all on table public.funnel_daily from public, anon, authenticated; +grant select, insert on table public.funnel_events to service_role; +grant select on table public.funnel_daily to service_role; + +-- Functions: Postgres lets everyone run new functions by default. Take that away. +revoke all on function public.waitlist_new_code() from public, anon, authenticated; +revoke all on function public.waitlist_status(text) from public, anon, authenticated; +revoke all on function public.waitlist_join(text, text, text) from public, anon, authenticated; +grant execute on function public.waitlist_new_code() to service_role; +grant execute on function public.waitlist_status(text) to service_role; +grant execute on function public.waitlist_join(text, text, text) to service_role; + +-- Ask the Supabase API to pick up the new functions and table now. +notify pgrst, 'reload schema'; +``` + +## 3. Copy two values from Supabase + +1. In the left sidebar, click the gear icon, **Project Settings**. +2. Click **API**. (On newer dashboards the URL is under **Data API** and the keys are under **API Keys**.) +3. Copy the **Project URL**. It looks like `https://abcdefghijklmnop.supabase.co`. +4. Copy the **service_role** key. Click **Reveal** first. On newer dashboards it is on the **Legacy API keys** tab. A new secret key that starts with `sb_secret_` also works. + +**Warning: the service_role key is a secret.** It can read and delete every signup. Never share it, never paste it in a chat, an email or a document, and never commit it to the repo. It goes in exactly one place: Vercel, in the next step. + +## 4. Add the two values to Vercel + +1. Go to https://vercel.com and open the project **time-machine-site**. +2. Click **Settings**, then **Environment Variables**. +3. Add the first variable: + - Key: `SUPABASE_URL` + - Value: the Project URL from step 3 + - Environments: tick **Production** and **Preview** +4. Click **Save**. +5. Add the second variable: + - Key: `SUPABASE_SERVICE_ROLE_KEY` + - Value: the service_role key from step 3 + - Environments: tick **Production** and **Preview** + - If Vercel offers a **Sensitive** switch, turn it on. +6. Click **Save**. +7. Tell BOSS or your coding agent: "The Supabase variables are in Vercel. Please redeploy time-machine-site." The site only picks up new variables after a redeploy. + +## 5. Check it works + +1. Open https://time-machine-site.vercel.app/api/health in your browser. +2. It should show `{"ok":true,"waitlist":true}`. It never shows the key. +3. If it shows `"waitlist":false`, a variable is missing, misspelled, or the URL does not start with `https://`. Fix it in Vercel and redeploy again. +4. Sign up on the live site with your own email. You should get your place in the list and your referral link. +5. In Supabase, open **Table Editor**, then `waitlist`. Your row should be there. + +If the form says it could not save, the tables are missing or the key is wrong. Run step 2 again, check the key, and redeploy. The Vercel function log shows only a status code from Supabase, never an email or the key. + +## 6. Read your numbers + +Open **SQL Editor**, click **New query**, paste one of these, and click **Run**. + +**Newest signups:** + +```sql +select email, source, referred_by is not null as came_from_a_friend, created_at +from public.waitlist +order by created_at desc +limit 50; +``` + +**How many people are on the list:** + +```sql +select count(*) as signups from public.waitlist; +``` + +**The funnel, day by day (UTC), newest first:** + +```sql +select * from public.funnel_daily limit 30; +``` + +The columns are: `visits`, `visits_via_ref` (visits that came from a referral link), `signups`, `signups_via_ref` (signups that used someone's referral code), `shares` and `copies` (people who shared or copied their link). + +**Who brought the most friends:** + +```sql +select w.email, count(r.id) as friends_joined +from public.waitlist w +join public.waitlist r on r.referred_by = w.ref_code +group by w.email +order by friends_joined desc +limit 20; +``` + +**Delete one person** (when someone asks to be removed). Replace the example address: + +```sql +delete from public.waitlist where email_normalized = lower(trim('person@example.com')); +``` + +## Free projects pause + +Free Supabase projects pause after about a week with no activity. A paused project cannot save signups, and the form will say it could not save. Before any launch or post, open the Supabase dashboard and check the project is active. If it is paused, click **Restore project** and wait for it to come back. diff --git a/site/api/_lib/event.js b/site/api/_lib/event.js new file mode 100644 index 00000000..ea09e468 --- /dev/null +++ b/site/api/_lib/event.js @@ -0,0 +1,55 @@ +// Funnel event handler core. Pure: no Vercel, no globals. Fetch, env and log are injected. +// Stores only the event type, the page path, a via-referral flag, and the time (set by Postgres). +// No IP, no user agent, no cookies, no email, no referral code. +import { isAllowedOrigin, isConfigured, readJsonObject, reply, supabaseHeaders, supabaseOrigin } from "./waitlist.js"; + +export const MAX_EVENT_BYTES = 1024; +const UPSTREAM_TIMEOUT_MS = 3000; +const TYPES = new Set(["visit", "share", "copy"]); +// A plain site path: no query, no fragment, no @, at most 200 characters. So no code or email can ride along. +const PATH_RE = /^\/[A-Za-z0-9._~\/-]{0,199}$/; + +/** + * @param {{ method: string, origin?: string|null, host?: string|null, + * contentLength?: string|null, readBody: () => Promise }} req + * @param {{ env: Record, fetch: typeof fetch, + * log?: (message: string) => void }} deps + * @returns {Promise<{ status: number, headers: Record, body: object|null }>} + */ +export async function handleEvent(req, deps) { + const log = deps.log ?? (() => {}); + + if (req.method !== "POST") { + return reply(405, { ok: false, error: "method_not_allowed" }, { Allow: "POST" }); + } + if (!isAllowedOrigin(req.origin, req.host)) { + return reply(403, { ok: false, error: "forbidden_origin" }); + } + + const { data, fail } = await readJsonObject(req, MAX_EVENT_BYTES); + if (fail) return fail; + if (!TYPES.has(data.type) || typeof data.path !== "string" || !PATH_RE.test(data.path)) { + return reply(400, { ok: false, error: "bad_request" }); + } + if (data.via !== undefined && data.via !== "ref") return reply(400, { ok: false, error: "bad_request" }); + + if (!isConfigured(deps.env)) return reply(503, { ok: false, error: "not_configured" }); + + let res; + try { + res = await deps.fetch(`${supabaseOrigin(deps.env)}/rest/v1/funnel_events`, { + method: "POST", + headers: { ...supabaseHeaders(deps.env), Prefer: "return=minimal" }, + body: JSON.stringify({ type: data.type, path: data.path, via_ref: data.via === "ref" }), + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + } catch (err) { + log(`event: upstream request failed (${err && err.name ? err.name : "Error"})`); + return reply(502, { ok: false, error: "upstream" }); + } + if (!res.ok) { + log(`event: upstream returned status ${res.status}`); + return reply(502, { ok: false, error: "upstream" }); + } + return reply(204, null); +} diff --git a/site/api/_lib/event.test.mjs b/site/api/_lib/event.test.mjs new file mode 100644 index 00000000..86fd7cde --- /dev/null +++ b/site/api/_lib/event.test.mjs @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleEvent, MAX_EVENT_BYTES } from "./event.js"; + +// Synthetic data only. +const ENV = { SUPABASE_URL: "https://fake-project.supabase.test/", SUPABASE_SERVICE_ROLE_KEY: "fake-service-role-key-123" }; +const HOST = "timemachine.example.test"; + +function request(body, overrides = {}) { + const text = typeof body === "string" ? body : JSON.stringify(body); + return { + method: "POST", + origin: `https://${HOST}`, + host: HOST, + contentLength: String(new TextEncoder().encode(text).length), + readBody: async () => text, + ...overrides, + }; +} + +function setup({ env = ENV, response = () => new Response(null, { status: 201 }) } = {}) { + const fetch = vi.fn(async () => response()); + const log = vi.fn(); + return { fetch, log, deps: { env, fetch, log } }; +} + +const visit = { type: "visit", path: "/blog/first-post" }; + +describe("event handler", () => { + it("stores a visit and answers 204 with no body", async () => { + const { fetch, deps } = setup(); + const res = await handleEvent(request(visit), deps); + expect(res).toEqual({ status: 204, headers: {}, body: null }); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe("https://fake-project.supabase.test/rest/v1/funnel_events"); + expect(init.method).toBe("POST"); + expect(init.headers.apikey).toBe(ENV.SUPABASE_SERVICE_ROLE_KEY); + expect(init.headers.Authorization).toBe(`Bearer ${ENV.SUPABASE_SERVICE_ROLE_KEY}`); + expect(init.headers.Prefer).toBe("return=minimal"); + expect(JSON.parse(init.body)).toEqual({ type: "visit", path: "/blog/first-post", via_ref: false }); + }); + + it("stores share and copy events, and the via ref flag", async () => { + for (const type of ["share", "copy"]) { + const { fetch, deps } = setup(); + const res = await handleEvent(request({ type, path: "/", via: "ref" }), deps); + expect(res.status).toBe(204); + expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ type, path: "/", via_ref: true }); + } + }); + + it("stores only type, path and the via flag, dropping anything else sent", async () => { + const { fetch, deps } = setup(); + await handleEvent(request({ ...visit, email: "someone@example.test", code: "abc123xyz0", ua: "Bot" }), deps); + expect(Object.keys(JSON.parse(fetch.mock.calls[0][1].body)).sort()).toEqual(["path", "type", "via_ref"]); + }); + + it("rejects unknown types without calling Supabase", async () => { + for (const type of ["signup", "VISIT", "", null, 1, undefined]) { + const { fetch, deps } = setup(); + const res = await handleEvent(request({ ...visit, type }), deps); + expect(res).toEqual({ status: 400, headers: {}, body: { ok: false, error: "bad_request" } }); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it("rejects paths that are not plain site paths", async () => { + const bad = ["", "blog", "https://elsewhere.test/", "/?ref=abc123xyz0", "/#x", "/a@b.test", "/" + "x".repeat(200), null, 5]; + for (const path of bad) { + const { fetch, deps } = setup(); + const res = await handleEvent(request({ ...visit, path }), deps); + expect(res.status).toBe(400); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it("rejects a via value other than ref", async () => { + for (const via of ["share", true, "", null]) { + const { fetch, deps } = setup(); + const res = await handleEvent(request({ ...visit, via }), deps); + expect(res.status).toBe(400); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it("rejects malformed JSON and non-object bodies", async () => { + for (const body of ["{not json", "[]", "null", '"visit"']) { + const { deps } = setup(); + const res = await handleEvent(request(body), deps); + expect(res.status).toBe(400); + } + }); + + it("refuses bodies over 1 KB by header and by actual size", async () => { + const big = { ...visit, pad: "x".repeat(MAX_EVENT_BYTES) }; + expect(MAX_EVENT_BYTES).toBe(1024); + const byHeader = setup(); + const r1 = await handleEvent(request(big), byHeader.deps); + expect(r1).toEqual({ status: 413, headers: {}, body: { ok: false, error: "too_large" } }); + const byBytes = setup(); + const r2 = await handleEvent(request(big, { contentLength: null }), byBytes.deps); + expect(r2.status).toBe(413); + expect(byHeader.fetch).not.toHaveBeenCalled(); + expect(byBytes.fetch).not.toHaveBeenCalled(); + }); + + it("allows only POST", async () => { + for (const method of ["GET", "HEAD", "PUT", "OPTIONS"]) { + const { fetch, deps } = setup(); + const res = await handleEvent(request(visit, { method }), deps); + expect(res).toEqual({ status: 405, headers: { Allow: "POST" }, body: { ok: false, error: "method_not_allowed" } }); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it("refuses other origins and a missing origin", async () => { + for (const origin of ["https://evil.example.test", null, "null"]) { + const { fetch, deps } = setup(); + const res = await handleEvent(request(visit, { origin }), deps); + expect(res).toEqual({ status: 403, headers: {}, body: { ok: false, error: "forbidden_origin" } }); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it("returns not_configured when either variable is missing", async () => { + for (const env of [{}, { SUPABASE_URL: ENV.SUPABASE_URL }, { SUPABASE_SERVICE_ROLE_KEY: "k" }]) { + const { fetch, deps } = setup({ env }); + const res = await handleEvent(request(visit), deps); + expect(res).toEqual({ status: 503, headers: {}, body: { ok: false, error: "not_configured" } }); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it("returns 502 upstream on failure and never logs the key", async () => { + const responses = [ + () => new Response(`{"message":"${ENV.SUPABASE_SERVICE_ROLE_KEY}"}`, { status: 500 }), + () => { + throw new Error(`down ${ENV.SUPABASE_SERVICE_ROLE_KEY}`); + }, + ]; + for (const response of responses) { + const { deps, log } = setup({ response }); + const res = await handleEvent(request(visit), deps); + expect(res).toEqual({ status: 502, headers: {}, body: { ok: false, error: "upstream" } }); + expect(log).toHaveBeenCalled(); + for (const args of log.mock.calls) expect(args.join(" ")).not.toContain(ENV.SUPABASE_SERVICE_ROLE_KEY); + } + }); + + it("never throws, even when the body cannot be read", async () => { + const { deps } = setup(); + const res = await handleEvent(request(visit, { readBody: async () => Promise.reject(new Error("broke")) }), deps); + expect(res.status).toBe(400); + }); +}); diff --git a/site/api/_lib/waitlist.js b/site/api/_lib/waitlist.js index 5729751c..89096d49 100644 --- a/site/api/_lib/waitlist.js +++ b/site/api/_lib/waitlist.js @@ -6,15 +6,18 @@ export const MAX_EMAIL_LENGTH = 254; const MAX_SOURCE_LENGTH = 200; const UPSTREAM_TIMEOUT_MS = 8000; +// Referral codes: exactly 10 characters from a to z and 0 to 9. Made in SQL, see 0002_referrals_and_funnel.sql. +const CODE_RE = /^[a-z0-9]{10}$/; + // Deliberately simple: one @, no spaces, a dot in the domain. const EMAIL_RE = /^[^\s@]{1,64}@[^\s@.][^\s@]*\.[^\s@]{2,}$/; const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); -const reply = (status, body, headers = {}) => ({ status, headers, body }); +export const reply = (status, body, headers = {}) => ({ status, headers, body }); // Both variables present and the URL parses. Used by the handler and by /api/health. export function isConfigured(env) { - return Boolean(env && env.SUPABASE_SERVICE_ROLE_KEY && supabaseInsertUrl(env)); + return Boolean(env && env.SUPABASE_SERVICE_ROLE_KEY && supabaseOrigin(env)); } export function normalizeEmail(value) { @@ -46,7 +49,8 @@ function cleanSource(value) { return s.startsWith("/") ? s : null; } -function supabaseInsertUrl(env) { +// The Supabase project origin, or null when SUPABASE_URL is missing or not an http(s) URL. +export function supabaseOrigin(env) { if (!env.SUPABASE_URL) return null; let base; try { @@ -55,50 +59,97 @@ function supabaseInsertUrl(env) { return null; } if (base.protocol !== "https:" && base.protocol !== "http:") return null; - return `${base.origin}/rest/v1/waitlist?on_conflict=email_normalized`; + return base.origin; } -/** - * @param {{ method: string, origin?: string|null, host?: string|null, - * contentLength?: string|null, readBody: () => Promise }} req - * @param {{ env: Record, fetch: typeof fetch, - * log?: (message: string) => void }} deps - * @returns {Promise<{ status: number, headers: Record, body: object }>} - */ -export async function handleWaitlist(req, deps) { - const log = deps.log ?? (() => {}); - - if (req.method !== "POST") { - return reply(405, { ok: false, error: "method_not_allowed" }, { Allow: "POST" }); - } - if (!isAllowedOrigin(req.origin, req.host)) { - return reply(403, { ok: false, error: "forbidden_origin" }); - } +export function supabaseHeaders(env) { + const key = env.SUPABASE_SERVICE_ROLE_KEY; + return { apikey: key, Authorization: `Bearer ${key}`, "Content-Type": "application/json" }; +} +// Reads a JSON object body of at most maxBytes. Returns { data } or { fail } with a ready reply. +export async function readJsonObject(req, maxBytes) { const declared = Number(req.contentLength); - if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { - return reply(413, { ok: false, error: "too_large" }); + if (Number.isFinite(declared) && declared > maxBytes) { + return { fail: reply(413, { ok: false, error: "too_large" }) }; } let text; try { text = await req.readBody(); } catch { - return reply(400, { ok: false, error: "bad_request" }); + return { fail: reply(400, { ok: false, error: "bad_request" }) }; } - if (typeof text !== "string") return reply(400, { ok: false, error: "bad_request" }); - if (new TextEncoder().encode(text).length > MAX_BODY_BYTES) { - return reply(413, { ok: false, error: "too_large" }); + if (typeof text !== "string") return { fail: reply(400, { ok: false, error: "bad_request" }) }; + if (new TextEncoder().encode(text).length > maxBytes) { + return { fail: reply(413, { ok: false, error: "too_large" }) }; } - let data; try { data = JSON.parse(text); } catch { - return reply(400, { ok: false, error: "bad_request" }); + return { fail: reply(400, { ok: false, error: "bad_request" }) }; } if (!data || typeof data !== "object" || Array.isArray(data)) { - return reply(400, { ok: false, error: "bad_request" }); + return { fail: reply(400, { ok: false, error: "bad_request" }) }; } + return { data }; +} + +const isCount = (value) => Number.isSafeInteger(value) && value >= 0; + +// Calls a Postgres function through PostgREST. Returns the parsed rows array, or null after logging +// a safe line. Logs only an error name or status code: upstream messages can quote the email back. +async function callRpc(name, args, deps, log) { + let res; + try { + res = await deps.fetch(`${supabaseOrigin(deps.env)}/rest/v1/rpc/${name}`, { + method: "POST", + headers: supabaseHeaders(deps.env), + body: JSON.stringify(args), + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); + } catch (err) { + log(`waitlist: upstream request failed (${err && err.name ? err.name : "Error"})`); + return null; + } + if (!res.ok) { + log(`waitlist: upstream returned status ${res.status}`); + return null; + } + let rows; + try { + rows = await res.json(); + } catch { + rows = null; + } + if (!Array.isArray(rows) || rows.length > 1) { + log("waitlist: upstream returned an unexpected shape"); + return null; + } + return rows; +} + +/** + * POST joins the list. GET ?code= looks up a place in the list. + * @param {{ method: string, origin?: string|null, host?: string|null, code?: string|null, + * contentLength?: string|null, readBody: () => Promise }} req + * @param {{ env: Record, fetch: typeof fetch, + * log?: (message: string) => void }} deps + * @returns {Promise<{ status: number, headers: Record, body: object }>} + */ +export async function handleWaitlist(req, deps) { + const log = deps.log ?? (() => {}); + + if (req.method === "GET") return handleStatus(req, deps, log); + if (req.method !== "POST") { + return reply(405, { ok: false, error: "method_not_allowed" }, { Allow: "GET, POST" }); + } + if (!isAllowedOrigin(req.origin, req.host)) { + return reply(403, { ok: false, error: "forbidden_origin" }); + } + + const { data, fail } = await readJsonObject(req, MAX_BODY_BYTES); + if (fail) return fail; // Honeypot. People never see this field. Bots that fill it get a quiet ok and nothing is stored. if (typeof data.company === "string" ? data.company.trim() !== "" : data.company != null) { @@ -111,32 +162,36 @@ export async function handleWaitlist(req, deps) { if (data.consent !== true) return reply(400, { ok: false, error: "consent_required" }); if (!isConfigured(deps.env)) return reply(503, { ok: false, error: "not_configured" }); - const url = supabaseInsertUrl(deps.env); - const key = deps.env.SUPABASE_SERVICE_ROLE_KEY; - let res; - try { - res = await deps.fetch(url, { - method: "POST", - headers: { - apikey: key, - Authorization: `Bearer ${key}`, - "Content-Type": "application/json", - Prefer: "return=minimal,resolution=ignore-duplicates", - }, - body: JSON.stringify({ email, consent: true, source: cleanSource(data.source) }), - signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), - }); - } catch (err) { - // Only the error name. Messages from fetch can include the request URL. - log(`waitlist: upstream request failed (${err && err.name ? err.name : "Error"})`); + // A badly shaped ref is dropped here. An unknown or own code is dropped in SQL. Neither blocks the signup. + const ref = typeof data.ref === "string" && CODE_RE.test(data.ref) ? data.ref : null; + const rows = await callRpc("waitlist_join", { p_email: email, p_source: cleanSource(data.source), p_ref: ref }, deps, log); + if (!rows) return reply(502, { ok: false, error: "upstream" }); + + // No row back means the email was already on the list. Nothing changed. + if (rows.length === 0) return reply(200, { ok: true }); + const { code, position, total } = rows[0] ?? {}; + if (typeof code !== "string" || !CODE_RE.test(code) || !isCount(position) || !isCount(total)) { + log("waitlist: upstream returned an unexpected shape"); return reply(502, { ok: false, error: "upstream" }); } + return reply(200, { ok: true, code, position, total }); +} - // 409 means the address is already there, so the person is on the list. - if (res.ok || res.status === 409) return reply(200, { ok: true }); +// GET ?code=. No Origin check: browsers do not send Origin on same origin GET requests. +// The answer holds no personal data, only counts, and no CORS header lets other sites read it. +async function handleStatus(req, deps, log) { + const code = req.code; + if (typeof code !== "string" || !CODE_RE.test(code)) return reply(400, { ok: false, error: "invalid_code" }); + if (!isConfigured(deps.env)) return reply(503, { ok: false, error: "not_configured" }); - // Status only. The upstream body can quote the email back. - log(`waitlist: upstream returned status ${res.status}`); - return reply(502, { ok: false, error: "upstream" }); + const rows = await callRpc("waitlist_status", { p_code: code }, deps, log); + if (!rows) return reply(502, { ok: false, error: "upstream" }); + if (rows.length === 0) return reply(404, { ok: false, error: "not_found" }); + const { position, referrals, total } = rows[0] ?? {}; + if (!isCount(position) || !isCount(referrals) || !isCount(total)) { + log("waitlist: upstream returned an unexpected shape"); + return reply(502, { ok: false, error: "upstream" }); + } + return reply(200, { ok: true, position, referrals, total }); } diff --git a/site/api/_lib/waitlist.test.mjs b/site/api/_lib/waitlist.test.mjs index 8473fbba..3adadac4 100644 --- a/site/api/_lib/waitlist.test.mjs +++ b/site/api/_lib/waitlist.test.mjs @@ -19,7 +19,13 @@ function request(body, overrides = {}) { }; } -function setup({ env = ENV, response = () => new Response(null, { status: 201 }) } = {}) { +const CODE = "abc123xyz0"; +const REF = "frnd000ref"; +const json = (value, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json" } }); +const joined = () => json([{ code: CODE, position: 7, total: 42 }]); + +function setup({ env = ENV, response = joined } = {}) { const fetch = vi.fn(async () => response()); const log = vi.fn(); return { fetch, log, deps: { env, fetch, log } }; @@ -28,26 +34,57 @@ function setup({ env = ENV, response = () => new Response(null, { status: 201 }) const good = { email: EMAIL, consent: true, source: "/blog/first-post", company: "" }; describe("waitlist handler", () => { - it("stores a valid signup in Supabase and returns ok", async () => { + it("stores a new signup through the join function and returns its code and position", async () => { const { fetch, deps } = setup(); const res = await handleWaitlist(request(good), deps); - expect(res).toEqual({ status: 200, headers: {}, body: { ok: true } }); + expect(res).toEqual({ status: 200, headers: {}, body: { ok: true, code: CODE, position: 7, total: 42 } }); expect(fetch).toHaveBeenCalledTimes(1); const [url, init] = fetch.mock.calls[0]; - expect(url).toBe("https://fake-project.supabase.test/rest/v1/waitlist?on_conflict=email_normalized"); + expect(url).toBe("https://fake-project.supabase.test/rest/v1/rpc/waitlist_join"); expect(init.method).toBe("POST"); expect(init.headers.apikey).toBe(ENV.SUPABASE_SERVICE_ROLE_KEY); expect(init.headers.Authorization).toBe(`Bearer ${ENV.SUPABASE_SERVICE_ROLE_KEY}`); - expect(init.headers.Prefer).toBe("return=minimal,resolution=ignore-duplicates"); - expect(JSON.parse(init.body)).toEqual({ email: NORMALIZED, consent: true, source: "/blog/first-post" }); + expect(JSON.parse(init.body)).toEqual({ p_email: NORMALIZED, p_source: "/blog/first-post", p_ref: null }); + }); + + it("answers ok without a code when the email is already on the list", async () => { + const { deps } = setup({ response: () => json([]) }); + const res = await handleWaitlist(request(good), deps); + expect(res).toEqual({ status: 200, headers: {}, body: { ok: true } }); }); - it("treats a repeat signup as ok (ignored duplicate or 409)", async () => { - for (const status of [201, 200, 409]) { - const { deps } = setup({ response: () => new Response(null, { status }) }); + it("passes a well formed ref code to Supabase", async () => { + const { fetch, deps } = setup(); + const res = await handleWaitlist(request({ ...good, ref: REF }), deps); + expect(res.status).toBe(200); + expect(JSON.parse(fetch.mock.calls[0][1].body).p_ref).toBe(REF); + }); + + it("ignores a bad ref and still signs the person up", async () => { + const bad = ["", "short", "UPPERCASE1", "abc123xyz01", "abc-23xyz0", " abc123xyz0", 1234567890, null, {}, ["abc123xyz0"]]; + for (const ref of bad) { + const { fetch, deps } = setup(); + const res = await handleWaitlist(request({ ...good, ref }), deps); + expect(res.body).toEqual({ ok: true, code: CODE, position: 7, total: 42 }); + expect(JSON.parse(fetch.mock.calls[0][1].body).p_ref).toBeNull(); + } + }); + + it("does not claim success when Supabase answers with an unexpected shape", async () => { + const shapes = [ + () => json({ code: CODE }), + () => json([{ code: "NOT-A-CODE", position: 1, total: 1 }]), + () => json([{ code: CODE, position: "1", total: 1 }]), + () => json([{ code: CODE, position: 1, total: 1 }, { code: CODE, position: 1, total: 1 }]), + () => new Response("not json", { status: 200 }), + () => new Response(null, { status: 409 }), + ]; + for (const response of shapes) { + const { deps, log } = setup({ response }); const res = await handleWaitlist(request(good), deps); - expect(res.status).toBe(200); - expect(res.body).toEqual({ ok: true }); + expect(res.status).toBe(502); + expect(res.body).toEqual({ ok: false, error: "upstream" }); + expect(log).toHaveBeenCalled(); } }); @@ -103,12 +140,12 @@ describe("waitlist handler", () => { } }); - it("allows only POST and says so in the Allow header", async () => { - for (const method of ["GET", "PUT", "DELETE", "OPTIONS"]) { + it("allows only GET and POST and says so in the Allow header", async () => { + for (const method of ["HEAD", "PUT", "DELETE", "OPTIONS"]) { const { fetch, deps } = setup(); const res = await handleWaitlist(request(good, { method }), deps); expect(res.status).toBe(405); - expect(res.headers).toEqual({ Allow: "POST" }); + expect(res.headers).toEqual({ Allow: "GET, POST" }); expect(res.body).toEqual({ ok: false, error: "method_not_allowed" }); expect(fetch).not.toHaveBeenCalled(); } @@ -167,9 +204,10 @@ describe("waitlist handler", () => { }), setup({ env: {} }), setup(), + setup({ response: () => json([{ email: NORMALIZED, key: ENV.SUPABASE_SERVICE_ROLE_KEY }]) }), ]; for (const { deps, log } of cases) { - const res = await handleWaitlist(request(good), deps); + const res = await handleWaitlist(request({ ...good, ref: REF }), deps); const outputs = [JSON.stringify(res), ...log.mock.calls.map((args) => args.join(" "))]; for (const out of outputs) { expect(out.toLowerCase()).not.toContain(NORMALIZED); @@ -179,6 +217,7 @@ describe("waitlist handler", () => { // The failure cases did log something, so the check above was not vacuous. expect(cases[0].log).toHaveBeenCalled(); expect(cases[1].log).toHaveBeenCalled(); + expect(cases[4].log).toHaveBeenCalled(); }); it("never throws, even when the body cannot be read", async () => { @@ -198,6 +237,68 @@ describe("waitlist handler", () => { it("stores only a path as the source", async () => { const { fetch, deps } = setup(); await handleWaitlist(request({ ...good, source: "https://elsewhere.test/x" }), deps); - expect(JSON.parse(fetch.mock.calls[0][1].body).source).toBeNull(); + expect(JSON.parse(fetch.mock.calls[0][1].body).p_source).toBeNull(); + }); +}); + +function statusRequest(code, overrides = {}) { + return { method: "GET", origin: null, host: HOST, code, readBody: async () => "", ...overrides }; +} + +describe("waitlist status lookup", () => { + it("returns position, referrals and total for a known code", async () => { + const { fetch, deps } = setup({ response: () => json([{ position: 3, referrals: 2, total: 42 }]) }); + const res = await handleWaitlist(statusRequest(CODE), deps); + expect(res).toEqual({ status: 200, headers: {}, body: { ok: true, position: 3, referrals: 2, total: 42 } }); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe("https://fake-project.supabase.test/rest/v1/rpc/waitlist_status"); + expect(init.method).toBe("POST"); + expect(init.headers.apikey).toBe(ENV.SUPABASE_SERVICE_ROLE_KEY); + expect(JSON.parse(init.body)).toEqual({ p_code: CODE }); + }); + + it("works without an Origin header, because browsers omit it on same origin GET", async () => { + const { deps } = setup({ response: () => json([{ position: 1, referrals: 0, total: 1 }]) }); + const res = await handleWaitlist(statusRequest(CODE, { origin: undefined }), deps); + expect(res.status).toBe(200); + }); + + it("returns 404 not_found for an unknown code", async () => { + const { deps } = setup({ response: () => json([]) }); + const res = await handleWaitlist(statusRequest(CODE), deps); + expect(res).toEqual({ status: 404, headers: {}, body: { ok: false, error: "not_found" } }); + }); + + it("returns 400 for a badly shaped code without calling Supabase", async () => { + for (const code of [null, "", "short", "ABC123XYZ0", "abc123xyz01", "abc123xyz%", "abc123xyz0 "]) { + const { fetch, deps } = setup(); + const res = await handleWaitlist(statusRequest(code), deps); + expect(res).toEqual({ status: 400, headers: {}, body: { ok: false, error: "invalid_code" } }); + expect(fetch).not.toHaveBeenCalled(); + } + }); + + it("returns not_configured when the settings are missing", async () => { + const { fetch, deps } = setup({ env: {} }); + const res = await handleWaitlist(statusRequest(CODE), deps); + expect(res).toEqual({ status: 503, headers: {}, body: { ok: false, error: "not_configured" } }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("returns 502 upstream when Supabase fails or answers oddly, and logs no key", async () => { + const responses = [ + () => new Response(`{"message":"${ENV.SUPABASE_SERVICE_ROLE_KEY}"}`, { status: 500 }), + () => json([{ position: 1 }]), + () => { + throw new Error(`down ${ENV.SUPABASE_SERVICE_ROLE_KEY}`); + }, + ]; + for (const response of responses) { + const { deps, log } = setup({ response }); + const res = await handleWaitlist(statusRequest(CODE), deps); + expect(res).toEqual({ status: 502, headers: {}, body: { ok: false, error: "upstream" } }); + expect(log).toHaveBeenCalled(); + for (const args of log.mock.calls) expect(args.join(" ")).not.toContain(ENV.SUPABASE_SERVICE_ROLE_KEY); + } }); }); diff --git a/site/api/event.js b/site/api/event.js new file mode 100644 index 00000000..b67072ae --- /dev/null +++ b/site/api/event.js @@ -0,0 +1,35 @@ +// POST /api/event. Vercel Node.js function using the Web Request and Response signature. +// Counts page visits, shares and link copies. Needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in Vercel. +import { handleEvent } from "./_lib/event.js"; + +async function handle(request) { + const result = await handleEvent( + { + method: request.method, + origin: request.headers.get("origin"), + host: request.headers.get("x-forwarded-host") || request.headers.get("host") || new URL(request.url).host, + contentLength: request.headers.get("content-length"), + readBody: () => request.text(), + }, + { env: process.env, fetch: globalThis.fetch, log: (message) => console.error(message) }, + ); + if (result.body === null) { + return new Response(null, { status: result.status, headers: { ...result.headers, "Cache-Control": "no-store" } }); + } + return new Response(JSON.stringify(result.body), { + status: result.status, + headers: { + ...result.headers, + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }, + }); +} + +export const GET = handle; +export const HEAD = handle; +export const POST = handle; +export const PUT = handle; +export const PATCH = handle; +export const DELETE = handle; +export const OPTIONS = handle; diff --git a/site/api/waitlist.js b/site/api/waitlist.js index c151ca6b..58b43adc 100644 --- a/site/api/waitlist.js +++ b/site/api/waitlist.js @@ -1,4 +1,5 @@ -// POST /api/waitlist. Vercel Node.js function using the Web Request and Response signature. +// POST /api/waitlist joins the list. GET /api/waitlist?code= looks up a place in the list. +// Vercel Node.js function using the Web Request and Response signature. // Needs two environment variables in Vercel: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY. import { handleWaitlist } from "./_lib/waitlist.js"; @@ -8,6 +9,7 @@ async function handle(request) { method: request.method, origin: request.headers.get("origin"), host: request.headers.get("x-forwarded-host") || request.headers.get("host") || new URL(request.url).host, + code: new URL(request.url).searchParams.get("code"), contentLength: request.headers.get("content-length"), readBody: () => request.text(), }, diff --git a/site/assets/device.css b/site/assets/device.css deleted file mode 100644 index 6d2b9ba6..00000000 --- a/site/assets/device.css +++ /dev/null @@ -1,127 +0,0 @@ -/* The Time Machine device on the home page. Same geometry, screen, dial and keys as the story page (assets/story.css). - The table shadow is a radial gradient (no per frame blur), the dial hint only spins on hover, - and the device sits in a box with a pointer tilt. Transform and opacity only. */ -.rig { --mono: var(--font-mono); --alu-1: #f1f0ec; --alu-2: #dcdad4; --alu-3: #c4c2bb; } -.face { position: absolute; backface-visibility: hidden; } -.f-front { - inset: 0; border-radius: 28px; transform: translateZ(17px); transform-style: preserve-3d; - background: - radial-gradient(140% 90% at 20% 0%, rgba(255, 255, 255, .75), transparent 55%), - repeating-linear-gradient(90deg, rgba(255, 255, 255, .05) 0 1px, rgba(0, 0, 0, .018) 1px 2px, transparent 2px 4px), - linear-gradient(170deg, var(--alu-1) 0%, #e6e4df 50%, var(--alu-2) 100%); - box-shadow: inset 0 1px 0 #fff, inset 0 -1px 0 rgba(0, 0, 0, .12), inset 1px 0 0 rgba(255, 255, 255, .6), inset -1px 0 0 rgba(0, 0, 0, .06); -} -.f-back { inset: 0; border-radius: 28px; transform: rotateY(180deg) translateZ(17px); background: #bebcb5; } -.f-top, .f-bot { left: 14px; top: 133px; width: 432px; height: 34px; } -.f-top { transform: rotateX(90deg) translateZ(150px); background: linear-gradient(90deg, #d8d6d0, #f3f2ee 40%, #e2e0da); } -.f-top::after { content: ""; position: absolute; left: 16px; top: 12px; width: 268px; height: 9px; border-radius: 5px; background: #1a1a18; box-shadow: inset 0 2px 3px #000; } -.f-bot { transform: rotateX(-90deg) translateZ(150px); background: linear-gradient(90deg, #a9a7a0, #bdbbb4 50%, #a4a29b); } -.f-l, .f-r { left: 213px; top: 14px; width: 34px; height: 272px; } -.f-l { transform: rotateY(-90deg) translateZ(230px); background: linear-gradient(180deg, #cfcdc6, #b3b1aa); } -.f-r { transform: rotateY(90deg) translateZ(230px); background: linear-gradient(180deg, #c9c7c0, #a9a79f); } -/* a soft shadow on the table */ -.dev::before { content: ""; position: absolute; left: -4%; right: -4%; top: 4%; bottom: -14%; border-radius: 50%; background: radial-gradient(closest-side, rgba(0, 0, 0, .3), rgba(0, 0, 0, .12) 60%, transparent); transform: translateZ(-30px); } - -.silk { font: 500 8.5px/1 var(--mono); letter-spacing: .16em; text-transform: uppercase; color: #77756e; } -.silk-wm { position: absolute; left: 26px; top: 20px; font-size: 9.5px; font-weight: 650; color: #3f3d38; } -.silk-model { position: absolute; left: 150px; top: 20px; } -.grille { position: absolute; right: 26px; top: 17px; width: 84px; height: 18px; opacity: .8; background: radial-gradient(circle, #7e7c75 1.3px, transparent 1.6px) 0 0 / 7px 6px; } - -.screen { position: absolute; left: 22px; top: 46px; width: 250px; height: 142px; padding: 11px 14px; overflow: hidden; border-radius: 12px; background: #0c0d0c; color: #eceae2; box-shadow: inset 0 2px 10px rgba(0, 0, 0, .9), 0 1px 0 rgba(255, 255, 255, .9), 0 -1px 0 rgba(0, 0, 0, .2); } -.scr-glass { position: absolute; inset: 0; z-index: 3; pointer-events: none; background: linear-gradient(155deg, rgba(255, 255, 255, .09), transparent 40%); } -.scr-top { display: flex; justify-content: space-between; align-items: baseline; font: 500 9px/1.2 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: rgba(236, 234, 226, .55); } -.scr-clock { font-size: 12px; letter-spacing: .02em; color: #eceae2; font-variant-numeric: tabular-nums; } -.scr-id { margin-top: 5px; font: 500 33px/1.1 var(--mono); letter-spacing: -.04em; } -.scr-note { margin-top: 1px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 11.5px; color: rgba(236, 234, 226, .78); } -.scr-log { position: absolute; left: 14px; right: 14px; top: 28px; overflow: hidden; white-space: nowrap; font: 9.6px/1.5 var(--mono); color: rgba(236, 234, 226, .85); opacity: 0; } -.scr-log p { margin: 0; overflow: hidden; text-overflow: ellipsis; } -.scr-log .hl { padding: 0 3px; background: #eceae2; color: #0c0d0c; } -.screen.mode-log .scr-log { opacity: 1; } -.screen.mode-log .scr-id, .screen.mode-log .scr-note, .screen.mode-log .scr-stat { opacity: 0; } -.scr-stat { position: absolute; left: 14px; bottom: 28px; display: flex; gap: 6px; align-items: center; font: 500 9.5px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: rgba(236, 234, 226, .6); } -.scr-stat.works { color: #3fd07f; } -.scr-stat.broken { color: #ff5a47; } -.scr-stat.works::before, .scr-stat.broken::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } -.scr-line { position: absolute; left: 16px; right: 16px; bottom: 14px; height: 8px; border-bottom: 1px solid rgba(236, 234, 226, .25); } -.scr-line i { position: absolute; bottom: -4px; left: calc(var(--x) * 20%); width: 7px; height: 7px; margin-left: -3.5px; border-radius: 50%; border: 1.2px solid rgba(236, 234, 226, .7); background: #0c0d0c; opacity: 0; } -.scr-line i.on { opacity: 1; } -.scr-line i.works { background: #3fd07f; border-color: #3fd07f; } -.scr-line i.broken { background: #ff5a47; border-color: #ff5a47; } -.scr-head { position: absolute; left: 0; bottom: -7px; width: 1px; height: 16px; background: #eceae2; will-change: transform; } -.scr-head::before { content: ""; position: absolute; top: -5px; left: -3px; border: 3.5px solid transparent; border-top: 5px solid #eceae2; } - -.led-wrap { position: absolute; left: 24px; top: 206px; display: flex; align-items: center; gap: 9px; } -.led { position: relative; width: 13px; height: 13px; border-radius: 50%; background: radial-gradient(circle at 40% 35%, #6b6a65, #3a3936); box-shadow: inset 0 1px 2px rgba(0, 0, 0, .6), 0 1px 0 #fff; } -.led i { position: absolute; inset: 0; border-radius: 50%; opacity: 0; transition: opacity .25s; } -.led-g { background: radial-gradient(circle at 40% 35%, #9bf5be, #1e9e57 70%); box-shadow: 0 0 12px 3px rgba(30, 158, 87, .55); } -.led-r { background: radial-gradient(circle at 40% 35%, #ffb2a8, #d93b2c 70%); box-shadow: 0 0 12px 3px rgba(217, 59, 44, .55); } -.led .led-hi { opacity: 1; background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, .8), transparent 35%); } -.led.works .led-g, .led.broken .led-r { opacity: 1; } - -.dial { position: absolute; left: 262px; top: 38px; width: 180px; height: 180px; pointer-events: none; } -.dial i { position: absolute; left: 50%; top: 50%; width: 0; height: 0; transform: rotate(var(--a)); } -.dial i::before { content: ""; position: absolute; left: -.75px; top: -89px; width: 1.5px; height: 9px; background: #55534d; } -.dial b { position: absolute; left: -6px; top: -104px; width: 12px; text-align: center; font: 500 9px/1 var(--mono); color: #55534d; transform: rotate(calc(var(--a) * -1)); } - -.knob { position: absolute; left: 287px; top: 63px; width: 130px; height: 130px; border-radius: 50%; outline: none; touch-action: none; transform-style: preserve-3d; } -.knob::before { content: ""; position: absolute; inset: -6px; border-radius: 50%; background: radial-gradient(circle, rgba(0, 0, 0, .35) 60%, transparent 72%); } -.knob-rot { position: absolute; inset: 0; transform-style: preserve-3d; will-change: transform; } -.kl { position: absolute; inset: 0; border-radius: 50%; transform: translateZ(calc(var(--z) * 2.2px)); background: repeating-conic-gradient(#1d1d1b 0 3deg, #3b3b38 3deg 6deg); } -.kcap { position: absolute; inset: 9px; border-radius: 50%; transform: translateZ(18px); background: radial-gradient(circle at 50% 60%, #2c2c2a 0%, #3a3a37 55%, #4a4a46 80%, #262624 100%); box-shadow: inset 0 2px 1px rgba(255, 255, 255, .18), inset 0 -6px 12px rgba(0, 0, 0, .5); } -.kcap::after { content: ""; position: absolute; inset: 0; border-radius: 50%; background: conic-gradient(from 200deg, transparent, rgba(255, 255, 255, .08), transparent 30%, rgba(255, 255, 255, .05) 55%, transparent 70%); } -.kmark { position: absolute; left: 50%; top: 7px; width: 4px; height: 30px; margin-left: -2px; border-radius: 3px; background: #ff791f; } -.knob-hint { position: absolute; inset: -16px; border-radius: 50%; border: 1.5px dashed rgba(20, 20, 18, .45); opacity: 0; transform: translateZ(1px); transition: opacity .2s; } -@keyframes spin { to { transform: translateZ(1px) rotate(360deg); } } -.rig.live .knob { cursor: grab; pointer-events: auto; } -.rig.live .knob:active { cursor: grabbing; } -.rig.live .key { pointer-events: auto; cursor: pointer; } -.knob:focus-visible .knob-hint { opacity: 1 !important; border-style: solid; border-color: #b83e08; border-width: 3px; } - -.keys { position: absolute; left: 22px; top: 232px; display: flex; gap: 12px; transform-style: preserve-3d; } -.key { position: relative; width: 54px; height: 40px; padding: 0; border: 0; border-radius: 10px; background: #1c1c1a; color: #141412; transform-style: preserve-3d; box-shadow: inset 0 2px 3px rgba(0, 0, 0, .7), 0 1px 0 #fff; pointer-events: none; } -.key-wide { width: 76px; } -.key:focus-visible { outline: 2px solid #141412; outline-offset: 3px; } -.key .cap { position: absolute; inset: 2px; display: grid; place-items: center; border-radius: 8px; transform: translateZ(7px); background: linear-gradient(180deg, #fbfaf7, #e2e0da); box-shadow: inset 0 -3px 0 rgba(0, 0, 0, .08), 0 2px 0 #b4b2ab, 0 3px 5px rgba(0, 0, 0, .25); transition: transform .12s; } -.key .glyph { display: inline-flex; font-size: 12px; } -.key .glyph i { display: block; width: .62em; height: .72em; background: #141412; clip-path: polygon(100% 0, 100% 100%, 0 50%); } -.key .glyph i + i { margin-left: -.08em; } -.key .lamp { position: absolute; top: 7px; left: 8px; z-index: 2; width: 5px; height: 5px; border-radius: 50%; transform: translateZ(8px); background: #b9b7b0; } -.key .lamp::after { content: ""; position: absolute; inset: -3px; border-radius: 50%; opacity: 0; transition: opacity .2s; background: radial-gradient(circle, #fff 30%, rgba(255, 255, 255, .6) 50%, transparent 70%); box-shadow: 0 0 8px 2px rgba(255, 255, 255, .9); } -.key.lit .lamp { background: #fff; box-shadow: 0 0 0 1px #2a2a28; } -.key.lit .lamp::after { opacity: 1; } -.key.down .cap { transform: translateZ(2px); } -.key > .silk { position: absolute; left: 0; right: 0; top: 46px; text-align: center; } - - -/* The device lives in a box instead of a fixed layer, and answers a fine pointer. */ -.rig { position: relative; perspective: 1700px; perspective-origin: 50% 40%; } -.dev { position: absolute; left: 50%; top: 50%; width: 460px; height: 300px; margin: -150px 0 0 -230px; transform-style: preserve-3d; will-change: transform; } -.sheen { position: absolute; inset: 0; z-index: 0; overflow: hidden; border-radius: inherit; pointer-events: none; } -.sheen i { position: absolute; left: -50%; top: -50%; width: 200%; height: 200%; opacity: .55; background: radial-gradient(closest-side, rgba(255, 255, 255, .7), transparent 70%); } -.rig .key { pointer-events: auto; cursor: pointer; } -.rig .knob { pointer-events: auto; cursor: grab; } -.rig .knob:active { cursor: grabbing; } -@media (hover: hover) and (pointer: fine) { - .sheen i { will-change: transform; } - .key:hover .cap { transform: translateZ(9px); } - .knob:hover .knob-hint { opacity: 1; } -} -@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) { - :root:not([data-tier="low"]) .knob:hover .knob-hint { animation: spin 14s linear infinite; } -} -.key.lamp-on .lamp { background: #fff; box-shadow: 0 0 0 1px #2a2a28; } -.key.lamp-on .lamp::after { opacity: 1; } -.rig.off .scr-top, .rig.off .scr-id, .rig.off .scr-note, .rig.off .scr-stat { opacity: 0; } -.scr-top, .scr-id, .scr-note, .scr-stat { transition: opacity .3s; } -@media (prefers-reduced-motion: reduce) { - .led i, .key .cap, .scr-top, .scr-id, .scr-note, .scr-stat { transition: none; } -} -/* Touch: each key's tap area reaches past its cap, so the scaled keys still clear 44 px on a 360 px phone. */ -@media (pointer: coarse) { .rig .key::after { content: ""; position: absolute; inset: -14px -6px; } } -/* Weak devices (data-tier="low"): fewer 3D layers and no glow. The device still powers on and turns. */ -:root[data-tier="low"] .kl:not(:first-child):not(:nth-child(4)):not(:nth-child(8)) { display: none; } -:root[data-tier="low"] .keys, :root[data-tier="low"] .key { transform-style: flat; } -:root[data-tier="low"] .key .cap { transform: none; } -:root[data-tier="low"] .key.down .cap { transform: translateY(3px); } -:root[data-tier="low"] .sheen { display: none; } -:root[data-tier="low"] .led-g, :root[data-tier="low"] .led-r { box-shadow: none; } diff --git a/site/assets/device.js b/site/assets/device.js deleted file mode 100644 index de69711f..00000000 --- a/site/assets/device.js +++ /dev/null @@ -1,231 +0,0 @@ -/* The Time Machine device in the home page hero. - The same save points, dial detents and recorded verdicts as the story page (assets/story.js). - Power on plays once. The pointer tilt and sheen run only on a fine pointer, only while the pointer moves, - and only while the device is on screen. Weak devices (data-tier="low") get a shorter power on and no tilt. - Story data is the synthetic Marigold Tips demo. IDs and verdicts match a real `tm demo` run. - Pressing Check replays the recorded verdict. Nothing runs in your browser. */ -(() => { - "use strict"; - const $ = (s, r = document) => r.querySelector(s); - const $$ = (s, r = document) => [...r.querySelectorAll(s)]; - const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); - const root = document.documentElement; - const RM = matchMedia("(prefers-reduced-motion: reduce)").matches; - const FINE = matchMedia("(hover: hover) and (pointer: fine)").matches; - let LITE = root.dataset.tier === "low"; - - const rig = $("#rig"), dev = $("#dev"), knob = $("#knob"), knobRot = $("#knobRot"), led = $("#led"); - if (!rig) return; - const D = { mode: $("#dMode"), clock: $("#dClock"), id: $("#dId"), note: $("#dNote"), stat: $("#dStat"), head: $("#dHead") }; - const dots = $$("#dLine i"); - const outs = $$("[data-dev-out]"); - const keys = {}; $$(".key", rig).forEach((k) => (keys[k.dataset.k] = k)); - - const CPS = [ - { id: "cp-0001", t: "10:05:00", note: "Initial tip calculator", v: "works" }, - { id: "cp-0002", t: "10:20:00", note: "Added split bill", v: "works" }, - { id: "cp-0003", t: "10:42:07", note: "Added dark mode", v: "works" }, - { id: "cp-0004", t: "10:51:00", note: "Agent: fixed lint warnings", v: "broken" }, - { id: "cp-0005", t: "11:04:00", note: "Changed currency", v: "broken" }, - { id: "cp-0006", t: "11:12:00", note: "Added a dependency", v: "broken" }, - ]; - const DET = (i) => -135 + i * 54; - let sel = 5, checked = {}, checking = -1, flash = null, flashT = 0, angle = null, on = RM; - const cue = kind => window.TMSound?.cue(kind); - let filmTimers = []; - function stopFilm() { filmTimers.forEach(clearTimeout); filmTimers = []; dev.classList.remove("glitch"); dev.dataset.scene = "idle"; } - function film() { - finishIntro(); stopFilm(); clearTimeout(flashT); flash = null; checking = -1; sel = 5; - checked[CPS[5].id] = "broken"; dev.dataset.scene = "break"; render(); cue("failure"); - if (RM) { - sel = 2; checked[CPS[2].id] = "works"; dev.dataset.scene = "working"; render(); return; - } - dev.classList.add("glitch"); - const later = (ms, fn) => filmTimers.push(setTimeout(fn, ms)); - later(600, () => dev.classList.remove("glitch")); - later(900, () => { dev.dataset.scene = "rewind"; render(); cue("rewind"); }); - [4, 3, 2].forEach((i, step) => later(1100 + step * 400, () => { sel = i; checked[CPS[i].id] = CPS[i].v; render(); cue("detent"); })); - later(2300, () => { dev.dataset.scene = "working"; render(); cue("success"); }); - } - - /* ---------- the screen ---------- */ - function render() { - const c = CPS[sel], v = checked[c.id]; - let s = { mode: "saved", clock: c.t, id: c.id, note: c.note, stat: v || "not checked yet", sc: v || "" }; - if (dev.dataset.scene === "rewind") s.mode = "rewinding"; - if (checking === sel) s = Object.assign(s, { mode: "checking", stat: "checking · network off", sc: "" }); - if (flash) s = Object.assign(s, flash); - D.mode.textContent = s.mode; D.clock.textContent = s.clock; D.id.textContent = s.id; D.note.textContent = s.note; D.stat.textContent = s.stat; - D.stat.className = "scr-stat " + s.sc; - dots.forEach((el, i) => { - const dv = checked[CPS[i].id]; - el.classList.toggle("on", on); - el.classList.toggle("works", dv === "works"); - el.classList.toggle("broken", dv === "broken"); - }); - D.head.style.transform = `translateX(${(sel * 0.2 * 218).toFixed(1)}px)`; - led.classList.toggle("works", v === "works" && checking !== sel); - led.classList.toggle("broken", v === "broken" && checking !== sel); - keys.check.classList.toggle("lit", checking === sel); - if (angle == null) knobRot.style.transform = `rotate(${DET(sel)}deg)`; - knob.setAttribute("aria-valuenow", String(sel + 1)); - knob.setAttribute("aria-valuetext", `${c.id}, ${c.note}, ${s.stat}`); - const line = `${c.id} · ${c.note} · ${s.stat}`; - outs.forEach((el) => { if (el.textContent !== line) el.textContent = line; }); - } - function say(o, ms) { flash = o; clearTimeout(flashT); flashT = setTimeout(() => { flash = null; render(); }, ms || 1800); render(); } - - /* ---------- actions ---------- */ - function press(k) { const el = keys[k]; el.classList.add("down"); setTimeout(() => el.classList.remove("down"), 140); } - function choose(i) { i = clamp(Math.round(i), 0, 5); if (i !== sel) { cue("detent"); sel = i; checking = -1; flash = null; } render(); } - function rewind() { press("rewind"); choose(sel - 1); } - function check() { - const i = sel; checking = i; flash = null; press("check"); render(); - setTimeout(() => { if (checking !== i) return; checked[CPS[i].id] = CPS[i].v; checking = -1; render(); cue(CPS[i].v === "works" ? "success" : "failure"); }, RM ? 0 : 650); - } - function save() { press("save"); say({ mode: "save", id: "auto", note: "Before each prompt and rm -rf", stat: "outside your folder", sc: "" }); } - function recover() { - press("recover"); - const c = CPS[sel]; - if (checked[c.id] === "works") say({ mode: "recover", id: "tm/recover", note: c.id + " · new branch", stat: "nothing pushed", sc: "" }, 2400); - else say({ mode: "recover", id: "check first", note: "Press Check to test " + c.id, stat: checked[c.id] || "not checked yet", sc: checked[c.id] || "" }); - } - const ACT = { save, check, recover, rewind, film }; - const act = k => { finishIntro(); stopFilm(); ACT[k](); }; - Object.keys(keys).forEach((k) => keys[k].addEventListener("click", () => act(k))); - $$("[data-dev]").forEach((b) => b.addEventListener("click", () => act(b.dataset.dev))); - - /* ---------- the dial: grab, turn, keys ---------- */ - let drag = null; - knob.addEventListener("pointerdown", (e) => { - finishIntro(); stopFilm(); - const r = knob.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2; - drag = { cx, cy, a0: Math.atan2(e.clientY - cy, e.clientX - cx), ang: DET(sel) }; - knob.setPointerCapture(e.pointerId); e.preventDefault(); - }); - knob.addEventListener("pointermove", (e) => { - if (!drag) return; - const a = Math.atan2(e.clientY - drag.cy, e.clientX - drag.cx); - let d = ((a - drag.a0) * 180) / Math.PI; if (d > 180) d -= 360; if (d < -180) d += 360; - drag.a0 = a; drag.ang = clamp(drag.ang + d, -150, 150); angle = drag.ang; - knobRot.style.transform = `rotate(${angle.toFixed(1)}deg)`; - choose((angle + 135) / 54); - }); - const endDrag = () => { - if (!drag) return; - drag = null; angle = null; - if (!RM) { knobRot.style.transition = "transform .18s cubic-bezier(.3,1.6,.5,1)"; setTimeout(() => (knobRot.style.transition = ""), 200); } - render(); - }; - knob.addEventListener("pointerup", endDrag); knob.addEventListener("pointercancel", endDrag); - knob.addEventListener("keydown", (e) => { - finishIntro(); stopFilm(); - const m = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1 }[e.key]; - if (m) choose(sel + m); - else if (e.key === "Home") choose(0); - else if (e.key === "End") choose(5); - else if (e.key === "Enter" || e.key === " ") check(); - else return; - e.preventDefault(); - }); - - /* ---------- size and pose ---------- */ - let mob = innerWidth <= 820; - const base = () => { - const d = rig.dataset; - return { rx: +(mob ? d.mrx : d.rx) || 0, ry: +(mob ? d.mry : d.ry) || 0, rz: +(mob ? d.mrz : d.rz) || 0 }; - }; - let scale = 1, cur = base(), tgt = base(), raf = 0, visible = true; - const pose = (p, lift) => `translate3d(0,${lift || 0}px,0) scale(${scale.toFixed(4)}) rotateX(${p.rx.toFixed(2)}deg) rotateY(${p.ry.toFixed(2)}deg) rotateZ(${p.rz.toFixed(2)}deg)`; - function fit(w, h) { - mob = innerWidth <= 820; - scale = Math.min(w / 460, h / 300) * (+rig.dataset.fill || 0.86); - cur = base(); tgt = base(); - dev.style.transform = pose(cur); - } - // One size read at boot so power on starts at the right scale. After that the ResizeObserver hands over the size. - fit(rig.clientWidth, rig.clientHeight); - new ResizeObserver((es) => { const r = es[es.length - 1].contentRect; fit(r.width, r.height); }).observe(rig); - - /* Sheen and tilt follow the pointer. One rAF loop that stops when it settles, or when the device leaves the screen. */ - const front = $(".f-front", dev); - const sheen = document.createElement("div"); sheen.className = "sheen"; sheen.setAttribute("aria-hidden", "true"); - const glint = document.createElement("i"); sheen.appendChild(glint); front.prepend(sheen); - let gx = -0.2, gy = -0.35, tgx = gx, tgy = gy; - glint.style.transform = `translate3d(${gx * 50}%,${gy * 50}%,0)`; - function step() { - raf = 0; - if (!visible || document.hidden) return; - const k = 0.14; - let moving = false; - for (const a of ["rx", "ry", "rz"]) { cur[a] += (tgt[a] - cur[a]) * k; if (Math.abs(tgt[a] - cur[a]) > 0.02) moving = true; else cur[a] = tgt[a]; } - gx += (tgx - gx) * k; gy += (tgy - gy) * k; - if (Math.abs(tgx - gx) + Math.abs(tgy - gy) > 0.002) moving = true; - dev.style.transform = pose(cur); - glint.style.transform = `translate3d(${(gx * 50).toFixed(2)}%,${(gy * 50).toFixed(2)}%,0)`; - if (moving) raf = requestAnimationFrame(step); - } - if ("IntersectionObserver" in window) { - new IntersectionObserver((es) => { - visible = es[es.length - 1].isIntersecting; - if (!visible) { if (raf) { cancelAnimationFrame(raf); raf = 0; } stopFilm(); } - }).observe(rig); - } - if (FINE && !RM) { - const zone = rig.closest("[data-tilt-zone]") || rig; - let box = null; - zone.addEventListener("pointerenter", () => { box = null; }); - addEventListener("scroll", () => { box = null; }, { passive: true }); - zone.addEventListener("pointermove", (e) => { - if (drag || LITE || !visible) return; - if (!box) box = zone.getBoundingClientRect(); - const b = base(); - const x = clamp((e.clientX - box.left) / box.width - 0.5, -0.5, 0.5), y = clamp((e.clientY - box.top) / box.height - 0.5, -0.5, 0.5); - tgt = { rx: b.rx - y * 12, ry: b.ry + x * 18, rz: b.rz }; - tgx = x * 0.9; tgy = y * 0.9; - if (!raf) raf = requestAnimationFrame(step); - }); - zone.addEventListener("pointerleave", () => { if (LITE) return; tgt = base(); tgx = -0.2; tgy = -0.35; if (!raf) raf = requestAnimationFrame(step); }); - } - document.addEventListener("visibilitychange", () => { - if (document.hidden) { if (raf) cancelAnimationFrame(raf); raf = 0; stopFilm(); finishIntro(); } - }); - - /* A short frame-time probe after load. Slow pacing can only downgrade the tier, never upgrade it. */ - function probe() { - if (LITE || RM || document.hidden) return; - let prev = 0, n = 0, slow = 0; - const tick = (t) => { - if (document.hidden) return; - if (prev && ++n > 3 && t - prev > 25) slow++; - prev = t; - if (n < 28) requestAnimationFrame(tick); - else if (slow >= 6) { LITE = true; root.dataset.tier = "low"; tgt = base(); tgx = -0.2; tgy = -0.35; if (!raf) raf = requestAnimationFrame(step); } - }; - requestAnimationFrame(tick); - } - addEventListener("load", probe); - - /* The first-visit entrance is immediately skippable. It never locks the page. */ - let introTimers = [], introAnimations = []; - function finishIntro() { - introTimers.forEach(clearTimeout); introTimers = []; - introAnimations.forEach(a => a.cancel()); introAnimations = []; - root.dataset.intro = "done"; rig.classList.remove("off"); on = true; - Object.values(keys).forEach(k => k.classList.remove("lamp-on")); render(); - } - addEventListener("tm:intro-end", finishIntro); - if (RM || root.dataset.intro !== "playing" || scrollY > 100) { finishIntro(); return; } - rig.classList.add("off"); render(); - const b = base(), light = root.dataset.introLight === "true"; - const later = (ms, fn) => introTimers.push(setTimeout(fn, ms)); - introAnimations.push(dev.animate([{ opacity: 0, transform: pose({ rx: b.rx + (light ? 0 : 24), ry: b.ry, rz: b.rz }, light ? 12 : 45) }, { opacity: 1, transform: pose(b) }], - { duration: light ? 700 : 2200, easing: "cubic-bezier(.2,.8,.2,1)" })); - if (!light) introAnimations.push(knobRot.animate([{ transform: `rotate(${DET(5) - 150}deg)` }, { transform: `rotate(${DET(5)}deg)` }], { duration: 2400, easing: "cubic-bezier(.2,.9,.2,1)" })); - ["save", "check", "recover", "rewind"].forEach((k, i) => { - later(250 + i * 150, () => keys[k].classList.add("lamp-on")); - later(500 + i * 150, () => keys[k].classList.remove("lamp-on")); - }); - later(light ? 450 : 1100, () => { rig.classList.remove("off"); on = true; render(); }); - later(light ? 1200 : 2900, finishIntro); -})(); diff --git a/site/assets/home.css b/site/assets/home.css index dde5ef7e..4770e46f 100644 --- a/site/assets/home.css +++ b/site/assets/home.css @@ -1,276 +1,82 @@ -/* Time Machine home page: concept A, "Editorial". design/SITE_V3_DIRECTION.md. - Links after site.css, device.css and ladder.css. Mobile first: desktop rules sit in min-width queries. - Editorial blue for information. Warm orange for the physical controls. Green and red only mean recorded verdicts. - Motion: transform and opacity only, reveals play once, no scroll handler. */ +/* Home page. One promise, real proof, one action. Shared tokens and components live in site.css. */ -.home { --max-page: 1320px; overflow-x: clip; font-size: 16.5px; } -.home main:focus { outline: none; } -.home code { font-size: .9em; } -.label { margin: 0; font: 500 11.5px/1.4 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); } -.label b { font-weight: 600; color: var(--accent); } -.fine-m { margin: 0; max-width: 60ch; font: 400 11.5px/1.55 var(--font-mono); color: var(--ink-3); } -.fine-m a { color: inherit; } -.fine-m code { font-size: 1em; } -.label code { font-size: 1em; } -.arr { display: inline-block; width: 7px; height: 7px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: translateY(-2px) rotate(-45deg); } -.rw { display: inline-flex; } -.rw i { width: .6em; height: .7em; background: currentColor; clip-path: polygon(100% 0, 100% 100%, 0 50%); } -.rw i + i { margin-left: -.08em; } +.label { margin: 0 0 14px; font: 500 11.5px/1.4 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); } +.body { margin: 18px 0 0; max-width: 54ch; color: var(--ink-2); font-size: var(--fs-body); line-height: 1.55; } -/* ---------- hero ---------- */ -.hero { padding: 20px 0 0; } -.mast { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 20px; padding-bottom: 12px; border-bottom: 1px solid var(--ink); } -.mast .dot { display: inline-block; width: 7px; height: 7px; margin-right: 8px; border: 1.5px solid currentColor; border-radius: 50%; vertical-align: 1px; } -.hero-grid { display: grid; gap: 8px; } -@media (max-width: 999px) { - .hero-copy { display: contents; } - .hero-copy .h1 { order: 1; } - .hero-copy .dek { order: 2; } - .hero-copy .ctas { order: 3; } - .hero-grid .devbox { order: 4; } - .hero-copy .gap-note { order: 5; } +/* Hero: always ink, whatever the color scheme. Components inside read these tokens. */ +.hero { + --paper: #0f0e0c; --card: #1a1916; --mist: #24221e; --line: rgba(255, 255, 255, .1); --line-2: rgba(255, 255, 255, .24); + --ink: #f3f0e8; --ink-2: #d6d1c6; --ink-3: #a9a397; --accent: #ffb27a; + --works-fg: #4ade80; --works-bg: #10291a; --works-dot: #22c55e; --broken-dot: #ef4444; + background: var(--paper); color: var(--ink); padding: clamp(18px, 3vw, 36px) 0 clamp(48px, 6vw, 88px); } -.home .h1 { margin: 22px 0 16px; font: 330 clamp(52px, 15.4vw, 150px)/.9 var(--font-display); font-variation-settings: "opsz" 72; letter-spacing: -.035em; text-wrap: balance; } -.dek { max-width: 34ch; margin: 0 0 20px; font: 400 clamp(19px, 5vw, 24px)/1.38 var(--font-display); font-variation-settings: "opsz" 18; letter-spacing: -.005em; color: var(--ink-2); text-wrap: pretty; } -.ctas { display: flex; flex-wrap: wrap; gap: 10px; } -.ctas .btn { flex: 1 1 auto; } -.gap-note { max-width: 46ch; margin: 20px 0 0; padding-top: 14px; border-top: 1px solid var(--line-2); font-size: 14px; line-height: 1.5; color: var(--ink-2); } -.gap-note b { color: var(--ink); } -.gap-note code { white-space: nowrap; } - -.devbox { position: relative; margin: 6px calc(var(--gutter) * -1) 0; } -.home .rig { height: min(78vw, 360px); } -.dev-cap { display: grid; gap: 12px; padding: 0 var(--gutter); } -.dev-read { margin: 0; min-height: 3em; font: 500 12px/1.5 var(--font-mono); color: var(--ink); } -.dev-read::before { content: "Screen: "; color: var(--ink-3); } -.dev-ctl { display: flex; gap: 8px; } -.dev-ctl .btn { flex: 1; min-height: 44px; font-size: 14.5px; } - -.ledger { display: grid; grid-template-columns: 1fr 1fr; margin-top: 36px; border-top: 1px solid var(--ink); } -.ledger div { padding: 16px 14px 18px 0; border-bottom: 1px solid var(--line-2); } -.ledger div:nth-child(odd) { padding-right: 14px; border-right: 1px solid var(--line-2); } -.ledger div:nth-child(even) { padding-left: 14px; } -.ledger b { display: block; font: 300 40px/1 var(--font-display); font-variation-settings: "opsz" 60; letter-spacing: -.03em; } -.ledger span { display: block; margin-top: 8px; font-size: 13.5px; line-height: 1.4; color: var(--ink-2); } - -/* ---------- sections ---------- */ -.sec { padding: 48px 0 52px; border-top: 1px solid var(--line); } -.sec-head { display: grid; gap: 14px; margin-bottom: 36px; } -.home .h2 { margin: 0; max-width: 18ch; font: 340 clamp(38px, 10vw, 92px)/.98 var(--font-display); font-variation-settings: "opsz" 72; letter-spacing: -.03em; } -.home .lead { max-width: 52ch; margin: 0; font-size: 17.5px; line-height: 1.5; color: var(--ink-2); } -.more { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; margin-top: 18px; font-weight: 500; color: var(--accent); text-decoration: none; } -.more:focus-visible, .more:active { text-decoration: underline; } -@media (hover: hover) and (pointer: fine) { .more:hover { text-decoration: underline; } } - -/* 01 the problem */ -.gap-grid { display: grid; gap: 40px; } -.quote { margin: 0; padding: 22px 0 0; border-top: 3px solid var(--accent); } -.quote p { margin: 0 0 14px; font: 400 italic clamp(28px, 7vw, 44px)/1.12 var(--font-display); font-variation-settings: "opsz" 48; letter-spacing: -.015em; } -.quote footer { font: 500 12px/1.5 var(--font-mono); color: var(--ink-3); } -.quote footer a { display: inline-flex; align-items: center; min-height: 44px; color: var(--accent); } -.mlog { margin: 0; padding: 0; list-style: none; font: 13px/1.4 var(--font-mono); } -.mlog li { display: grid; grid-template-columns: 46px minmax(0, 1fr) auto; gap: 10px; align-items: baseline; padding: 10px 0; border-top: 1px solid var(--line-2); } -.mlog time { color: var(--ink-3); font-variant-numeric: tabular-nums; } -.mlog code { font-size: 12px; color: var(--ink-3); } -.mlog .err { border-top-color: var(--ink); } -.mlog .err span { font-weight: 600; } -.vs { display: grid; gap: 14px; margin-top: 44px; } -.vs-row { display: grid; gap: 8px; } -.vs-k { margin: 0; font-weight: 600; line-height: 1.25; } -.vs-k span { display: block; font: 400 12.5px/1.4 var(--font-mono); color: var(--ink-3); } -.vs-bar { position: relative; min-height: 54px; padding: 10px 14px; overflow: hidden; display: flex; align-items: center; border-radius: 10px; background: var(--mist); } -.vs-bar i { position: absolute; inset: 0; transform-origin: 0 50%; transform: scaleX(var(--w)); background: var(--ink-3); transition: transform 1.1s var(--ease-out); } -.vs-row.tm .vs-bar i { background: var(--accent-btn); } -.js .vs:not(.in) .vs-bar i { transform: scaleX(0); } -.vs-bar span { position: relative; font: 12.5px/1.35 var(--font-mono); color: #fff; } -.vs-row:first-child .vs-bar span { color: var(--ink); padding-left: calc(36% + 6px); } - -/* 02 the proof */ -.verdicts { margin: 0; max-width: none; } -.verdicts .b { color: var(--broken-fg); } -.verdicts .g { color: var(--works-fg); font-style: italic; } -.js [data-stagger] [data-i] { opacity: 0; transform: translate3d(0, 14px, 0); transition: opacity .5s var(--ease-out), transform .5s var(--ease-out); transition-delay: calc(var(--i) * 160ms); } -.js [data-stagger].in [data-i] { opacity: 1; transform: none; } -.pf-grid { display: grid; gap: 22px; } -.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 0; padding: 0; list-style: none; } -.pc { display: flex; flex-direction: column; min-width: 0; padding: 14px; border-radius: 14px; background: var(--card); box-shadow: inset 0 0 0 1px var(--line); } -.pc-k { margin: 0; font: 500 11.5px/1.3 var(--font-mono); letter-spacing: .02em; text-transform: uppercase; color: var(--ink-3); } -.pc h3 { margin: 10px 0 4px; font: 400 21px/1.08 var(--font-display); font-variation-settings: "opsz" 24; letter-spacing: -.01em; } -.pc-m { margin: 0 0 14px; font: 400 11.5px/1.3 var(--font-mono); color: var(--ink-3); } -.pc-s { margin: 0; font: 400 11.5px/1.3 var(--font-mono); color: var(--ink-2); } -.stamp { align-self: flex-end; display: inline-flex; align-items: center; gap: 6px; margin: auto 0 10px; padding: 4px 9px; border: 2px solid currentColor; border-radius: 6px; font: 650 12px/1 var(--font-mono); letter-spacing: .1em; text-transform: uppercase; transform: rotate(-7deg); } -.st-b { color: var(--broken-fg); background: var(--broken-bg); } -.st-g { color: var(--works-fg); background: var(--works-bg); } -.sh { width: 7px; height: 7px; background: currentColor; } -.st-g .sh { border-radius: 50%; } -.st-b .sh { transform: rotate(45deg) scale(.86); border-radius: 1px; } -.js .cards[data-stagger] .stamp { opacity: 0; transform: rotate(-7deg) scale(1.6); transition: opacity .15s, transform .35s var(--spring-verdict); transition-delay: calc(var(--i) * 160ms + 260ms); } -.js .cards[data-stagger].in .stamp { opacity: 1; transform: rotate(-7deg) scale(1); } -.term { margin: 0; padding: 14px 16px; overflow-x: auto; border-radius: 12px; background: var(--term-bg); color: var(--term-ink); font: 12px/1.65 var(--font-mono); font-variant-ligatures: none; white-space: pre; -webkit-overflow-scrolling: touch; } -.term .p, .term .d { color: var(--term-dim); } -.term .pass { color: var(--t-pass); } -.term .fail { color: var(--t-fail); } -.term-cap { margin: 8px 0 0; } - -/* 03 how it works */ -.steps { display: grid; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ink); } -.step { display: grid; gap: 10px; align-content: start; min-width: 0; padding: 26px 0 30px; border-bottom: 1px solid var(--line-2); } -.step-n { font: 300 italic 52px/1 var(--font-display); font-variation-settings: "opsz" 72; color: var(--accent); } -.step h3 { margin: 0; font: 600 21px/1.2 var(--font-body); letter-spacing: -.02em; } -.step p { margin: 0; color: var(--ink-2); max-width: 40ch; } -.step .term { margin-top: 8px; white-space: pre-wrap; overflow-wrap: anywhere; } - -/* 04 agents */ -.ag { display: grid; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ink); } -.ag li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 14px; align-items: baseline; padding: 14px 0; border-bottom: 1px solid var(--line-2); } -.ag b { font-weight: 600; } -.ag li > span { grid-column: 1 / -1; font-size: 14.5px; color: var(--ink-2); } -.ag .tag { justify-self: end; } - -/* 05 the Mac app */ -.shots { display: grid; gap: 18px; margin: 0; } -.shot { margin: 0; min-width: 0; } -.win { overflow: hidden; border-radius: var(--r-window); background: var(--card); box-shadow: 0 0 0 1px var(--line-2), 0 24px 48px -30px rgba(21, 19, 15, .45); } -.win-bar { display: flex; align-items: center; gap: 7px; height: 30px; padding: 0 12px; border-bottom: 1px solid var(--line); font: 500 12px/1 var(--font-body); color: var(--ink-3); } -.win-bar i { width: 10px; height: 10px; border-radius: 50%; background: color-mix(in srgb, var(--ink) 16%, transparent); } -.win img { width: 100%; height: auto; } -.shot figcaption { margin-top: 10px; font: 12px/1.5 var(--font-mono); color: var(--ink-3); } -.app-note { margin-top: 28px; } -.feat { display: grid; margin: 18px 0 0; padding: 0; list-style: none; border-top: 1px solid var(--line-2); } -.feat li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 14px; align-items: baseline; padding: 14px 0; border-bottom: 1px solid var(--line-2); } -.feat li > span { grid-column: 1 / -1; font-size: 14.5px; color: var(--ink-2); } - - -/* 07 pricing */ -.price-g { display: grid; gap: 28px; } -.price-notes { margin: 18px 0 0; padding: 0; list-style: none; border-top: 1px solid var(--line-2); } -.price-notes li { padding: 12px 0; border-bottom: 1px solid var(--line-2); color: var(--ink-2); font-size: 15px; } -.price-notes b { color: var(--ink); } - -/* 08 FAQ */ -.faq { margin: 0; padding: 0; border-top: 1px solid var(--ink); } +.wordmark { margin: 0 0 clamp(18px, 3vw, 34px); font: 300 min(15.5vw, 172px)/.9 var(--font-display); font-variation-settings: "opsz" 72; letter-spacing: -.035em; white-space: nowrap; } +.wordmark span, .wordmark em { display: inline-block; } +.wordmark em { color: #ff791f; } +:root.js .wordmark span, :root.js .wordmark em { animation: wm-rise .9s var(--ease-out) both; } +:root.js .wordmark em { animation-delay: .08s; } +@keyframes wm-rise { from { opacity: 0; transform: translateY(.18em); } to { opacity: 1; transform: none; } } + +.hero-grid { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); gap: clamp(28px, 4vw, 64px); align-items: start; } +.status { display: flex; align-items: center; gap: 9px; margin: 0 0 16px; font: 500 12px/1.4 var(--font-mono); color: var(--ink-3); } +.status code { color: var(--ink); } +.status .dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: #ff791f; } +.hero-h { margin: 0; font: 340 clamp(34px, 1.3rem + 2.7vw, 60px)/1.04 var(--font-display); font-variation-settings: "opsz" 72; letter-spacing: -.022em; text-wrap: balance; } +.hero-h em { color: var(--accent); } +.dek { margin: 18px 0 0; max-width: 50ch; color: var(--ink-2); font-size: clamp(16.5px, .95rem + .3vw, 19px); line-height: 1.5; } + +.wl-hero { margin-top: 26px; } +.hero .wl input[type="email"] { background: var(--card); } +.see { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; margin-top: 6px; color: var(--ink-2); font-size: 15px; } +.arr { display: inline-block; width: 7px; height: 7px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(45deg) translateY(-2px); } + +.proof { margin: 0; } +.term { margin: 0; padding: 18px 20px; border-radius: 14px; background: #161512; color: #ecebe5; box-shadow: 0 0 0 1px rgba(255, 255, 255, .09); font: 400 13px/1.62 var(--font-mono); white-space: pre; overflow-x: auto; tab-size: 2; } +.term .p, .term .d { color: #8f8b82; } +.term .pass { color: #4ade80; } +.term .fail { color: #f87171; } +.proof figcaption { margin-top: 10px; font: 400 11.5px/1.55 var(--font-mono); color: var(--ink-3); } + +/* Sections */ +.sec { padding: clamp(56px, 8vw, 112px) 0; border-top: 1px solid var(--line); } +.two { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 24px clamp(32px, 6vw, 96px); align-items: start; } +.h2 { margin: 0; } +.quote { margin: 0; padding-top: 18px; border-top: 3px solid #ff791f; } +.quote p { margin: 0 0 10px; font: 400 italic clamp(24px, 1rem + 1.6vw, 36px)/1.15 var(--font-display); font-variation-settings: "opsz" 72; } +.quote footer a { display: inline-flex; align-items: center; min-height: 44px; font: 500 12.5px/1.4 var(--font-mono); color: var(--accent); } + +.steps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 36px 0 0; padding: 0; list-style: none; counter-reset: s; } +.steps li { padding: 22px; border-radius: var(--r-card); background: var(--card); box-shadow: var(--shadow-card); counter-increment: s; } +.steps h3 { margin: 0 0 8px; font: 400 var(--fs-h3)/1.15 var(--font-display); } +.steps h3::before { content: counter(s) ". "; color: #d14e0b; } +.steps p { margin: 0; color: var(--ink-2); line-height: 1.55; } +.not-back { margin: 22px 0 0; max-width: 70ch; color: var(--ink-2); } + +.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 clamp(24px, 4vw, 64px); margin: 30px 0 0; padding: 0; list-style: none; } +.facts li { display: grid; gap: 4px; padding: 18px 0; border-top: 1px solid var(--line-2); } +.facts b { font: 400 clamp(22px, 1rem + 1vw, 28px)/1.2 var(--font-display); } +.facts span { color: var(--ink-2); line-height: 1.5; } +.facts a { display: inline-flex; align-items: center; min-height: 44px; color: var(--accent); } + +.faq { border-top: 1px solid var(--ink); } .faq details { border-bottom: 1px solid var(--line-2); } -.faq summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 56px; padding: 12px 0; cursor: pointer; list-style: none; font: 400 clamp(20px, 4.6vw, 26px)/1.2 var(--font-display); font-variation-settings: "opsz" 24; letter-spacing: -.01em; } +.faq summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 56px; cursor: pointer; font-weight: 500; list-style: none; } .faq summary::-webkit-details-marker { display: none; } -.faq summary::after { content: ""; flex: none; width: 10px; height: 10px; border-right: 1.5px solid var(--accent); border-bottom: 1.5px solid var(--accent); transform: translateY(-3px) rotate(45deg); transition: transform .2s var(--ease-out); } -.faq details[open] summary::after { transform: translateY(2px) rotate(-135deg); } -.faq details p { max-width: 62ch; margin: 0 0 18px; color: var(--ink-2); } +.faq summary::after { content: ""; flex: none; width: 9px; height: 9px; border-right: 1.5px solid var(--accent); border-bottom: 1.5px solid var(--accent); transform: rotate(45deg); } +.faq details[open] summary::after { transform: translateY(3px) rotate(-135deg); } +.faq details p { margin: 0 0 18px; max-width: 62ch; color: var(--ink-2); line-height: 1.55; } -/* 09 founder and waitlist */ -.end-grid { display: grid; gap: 56px; } -.letter-p { max-width: 30ch; margin: 0 0 18px; font: 400 clamp(22px, 5.4vw, 30px)/1.28 var(--font-display); font-variation-settings: "opsz" 36; letter-spacing: -.01em; } -.letter-sig { margin: 26px 0 0; font-weight: 600; } -.letter-sig span { font: 400 13px/1.4 var(--font-mono); color: var(--ink-3); } -.letter-sig a { display: inline-flex; align-items: center; min-height: 44px; } -.join .h2 { margin-bottom: 14px; } -#waitlist { scroll-margin-top: 90px; margin-top: 22px; } +.join { text-align: center; } +.join .wrap { display: grid; justify-items: center; gap: 26px; } +.join .wl { width: 100%; } -/* reveals: once, transform and opacity only */ -.js .rv { opacity: 0; transform: translate3d(0, 14px, 0); transition: opacity .6s var(--ease-out), transform .6s var(--ease-out); } -.js .rv.in { opacity: 1; transform: none; } - -/* ---------- tablet and up ---------- */ -@media (min-width: 720px) { - .ledger { grid-template-columns: repeat(4, 1fr); } - .ledger div, .ledger div:nth-child(odd), .ledger div:nth-child(even) { padding: 18px 18px 22px; border-right: 1px solid var(--line-2); } - .ledger div:first-child { padding-left: 0; } - .ledger div:last-child { border-right: 0; } - .ledger b { font-size: 52px; } - .ctas .btn { flex: none; } - .vs-row { grid-template-columns: 240px 1fr; gap: 24px; align-items: center; } - .cards { grid-template-columns: repeat(4, 1fr); } - .steps { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .step { padding: 28px 28px 34px; border-bottom: 0; border-right: 1px solid var(--line-2); } - .step:first-child { padding-left: 0; } - .step:last-child { border-right: 0; padding-right: 0; } - .ag, .feat { grid-template-columns: 1fr 1fr; column-gap: 40px; } - .shots { grid-template-columns: 1.3fr 1fr; align-items: start; } - .faq { columns: 2; column-gap: 48px; border-top: 0; } - .faq details { break-inside: avoid; border-top: 1px solid var(--line-2); border-bottom: 0; } -} -@media (min-width: 1000px) { - .home .wrap { --gutter: max(32px, 4vw); } - .hero { padding-top: 26px; } - .hero-grid { grid-template-columns: minmax(0, 1.04fr) minmax(0, 1fr); gap: 2vw; align-items: center; } - .home .h1 { margin-top: clamp(26px, 5vh, 56px); font-size: clamp(84px, min(8.6vw, 15vh), 150px); } - .devbox { margin: 0 -2vw 0 0; } - .home .rig { height: clamp(380px, 54vh, 560px); } - .dev-cap { grid-template-columns: 1fr auto; align-items: center; padding: 0 0 0 4%; } - .dev-cap .fine-m { grid-column: 1 / -1; } - .dev-ctl .btn { flex: none; } - .ledger { margin-top: 28px; } - .sec { padding: 68px 0 72px; } - .sec-head { grid-template-columns: 220px 1fr; gap: 22px; margin-bottom: 36px; } - .sec-head > :not(.label) { grid-column: 2; } - .gap-grid { grid-template-columns: 1fr 1.1fr; gap: 6vw; align-items: start; } - .pf-grid { grid-template-columns: 1.4fr 1fr; gap: 28px; align-items: start; } - .cards { grid-template-columns: repeat(2, 1fr); } - .price-g { grid-template-columns: minmax(280px, .7fr) 1.6fr; gap: 4vw; align-items: center; } - .end-grid { grid-template-columns: 1fr 1fr; gap: 6vw; align-items: start; } +@media (max-width: 900px) { + .hero-grid, .two { grid-template-columns: minmax(0, 1fr); } + .steps { grid-template-columns: minmax(0, 1fr); } } -@media (min-width: 1000px) and (max-height: 820px) { - .home .h1 { font-size: clamp(76px, 14vh, 130px); margin-top: 18px; } - .dek { font-size: 21px; margin-bottom: 16px; } - .gap-note { margin-top: 14px; } -} -@media (prefers-reduced-motion: reduce) { - .js .rv, .js [data-stagger] [data-i] { opacity: 1; transform: none; transition: none; } - .js .cards[data-stagger] .stamp { opacity: 1; transform: rotate(-7deg); transition: none; } - .vs-bar i, .faq summary::after { transition: none; } - .js .vs:not(.in) .vs-bar i { transform: scaleX(var(--w)); } -} -/* Weak devices: no large soft shadows. */ -:root[data-tier="low"] .win { box-shadow: 0 0 0 1px var(--line-2); } - -/* The opener is a stage, never an overlay or a scroll lock. */ -.hero { --ink: #f3f0e8; --ink-2: #d0cbc0; --ink-3: #b2aca0; --line: #393630; --line-2: #514b42; --mist: #28251f; --accent: #b3c0ff; color: var(--ink); background: #100f0d; padding-top: 16px; } -.opening-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; min-height: 44px; } -.opening-controls { display: flex; gap: 8px; flex-wrap: wrap; } -.opening-controls .btn { min-height: 44px; padding: 0 14px; font-size: 12px; } -.opening-controls [hidden] { display: none; } -.opening-wordmark { margin: 26px 0 20px; font: 300 clamp(60px, 15.4vw, 236px)/1 var(--font-display); font-variation-settings: "opsz" 72; letter-spacing: -.04em; white-space: nowrap; text-align: center; } -.opening-wordmark .word-mask { display: inline-block; overflow: hidden; vertical-align: bottom; padding: .06em .07em 0 0; margin: -.06em -.07em 0 0; } -.word-mask > span, .word-mask > em { display: inline-block; } -.opening-wordmark em { color: inherit; } -.opening-line { display: flex; justify-content: space-between; position: relative; height: 10px; border-top: 1px solid #514b42; } -.opening-line i { width: 1px; height: 5px; background: #8f8779; } -.opening-line b { position: absolute; inset: -3px 0 auto; height: 5px; transform: translateX(40%); pointer-events: none; } -.opening-line b::before { content: ""; display: block; width: 5px; height: 5px; border-radius: 50%; background: #ff791f; } -.intro-enter { display: none; } -:root[data-intro="playing"] .intro-enter { display: inline-flex; } -:root[data-intro="playing"] .word-mask > * { animation: wordmark-wake 1.7s var(--ease-out) both; } -:root[data-intro="playing"] .word-mask:last-child > * { animation-delay: .18s; } -:root[data-intro="playing"] .opening-line b { animation: opening-rewind 2.8s var(--ease-inout) both; } -.hero .mast { padding-top: 4px; border: 0; } -.hero .ledger { margin-top: 22px; } -.hero .gap-note { margin-bottom: 20px; } -.hero .dev-ctl { flex-wrap: wrap; } -.hero .dev-ctl .btn { flex: 1 1 auto; } -.hero .dev-ctl [data-dev="film"] { flex-basis: 100%; } -.home .hero .h1 { font-size: clamp(48px, 7vw, 96px); } -.upper-transition { background: #100f0d; color: #f3f0e8; border-top: 1px solid #514b42; padding: 44px 0 48px; } -.upper-transition .label { color: #b2aca0; } -.upper-transition h2 { margin: 18px 0; font: 300 clamp(62px, 11vw, 172px)/.94 var(--font-display); letter-spacing: -.035em; } -.upper-transition h2 em { color: #f3f0e8; } -.transition-end { margin: 0 0 22px; color: #d0cbc0; font: 400 clamp(22px, 3vw, 34px)/1.2 var(--font-display); } -/* One dim and one return per play. No repeated flashes or camera shake. */ -.hero .dev.glitch .screen { animation: device-glitch .6s ease-out 1; } -.hero .dev.glitch .led { opacity: .4; } -.hero .dev[data-scene="rewind"] .knob-rot, .hero .dev[data-scene="rewind"] .scr-head { transition: transform .36s var(--ease-inout); } -@keyframes wordmark-wake { from { opacity: .2; transform: translateY(95%); } to { opacity: 1; transform: translateY(0); } } -@keyframes opening-rewind { 0%, 25% { opacity: 0; transform: translateX(100%); } 35% { opacity: 1; } 85%, 100% { opacity: 1; transform: translateX(40%); } } -@keyframes device-glitch { 0%, 100% { opacity: 1; transform: none; } 45% { opacity: .55; transform: translateX(2px); } } @media (max-width: 640px) { - .opening-wordmark { font-size: 25vw; white-space: normal; text-align: left; margin: 18px 0 16px; line-height: .87; } - .opening-wordmark .word-mask { display: block; width: fit-content; padding: .09em .06em 0 0; margin-top: -.09em; } - .opening-controls { gap: 6px; } - .home .hero .h1 { font-size: 13vw; margin: 10px 0 8px; } - .hero .mast .label { font-size: 11.5px; } - .hero .dek { font-size: 20px; margin-bottom: 8px; } -} -:root[data-intro-light="true"][data-intro="playing"] .word-mask > * { animation-duration: .7s; animation-delay: 0s; } -:root[data-intro-light="true"] .opening-line b { animation: none; } -@media (prefers-reduced-motion: reduce) { - :root[data-intro="playing"] .word-mask > *, :root[data-intro="playing"] .opening-line b, .hero .dev.glitch .screen { animation: none; } - .hero .dev[data-scene="rewind"] .knob-rot, .hero .dev[data-scene="rewind"] .scr-head { transition: none; } + .facts { grid-template-columns: minmax(0, 1fr); } + .term { padding: 14px; font-size: 11.5px; white-space: pre-wrap; overflow-wrap: anywhere; } } +@media (prefers-reduced-motion: reduce) { :root.js .wordmark span, :root.js .wordmark em { animation: none; } } +:root[data-tier="low"] .wordmark span, :root[data-tier="low"] .wordmark em { animation: none; } diff --git a/site/assets/home.js b/site/assets/home.js deleted file mode 100644 index 383697fc..00000000 --- a/site/assets/home.js +++ /dev/null @@ -1,63 +0,0 @@ -/* Time Machine home page: reveals that play once, and the Ladder fader. - The device in the hero lives in assets/device.js. No scroll handler and no per frame work at rest. - Pricing is planned. Nothing is for sale yet. See docs/adr/0002-pricing-ladder.md. */ -(() => { - "use strict"; - const $ = (s, r = document) => r.querySelector(s); - const $$ = (s, r = document) => [...r.querySelectorAll(s)]; - const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); - const RM = matchMedia("(prefers-reduced-motion: reduce)").matches; - - /* ---------- reveals: once, at 15 percent into view. Transform and opacity only. ---------- */ - const els = $$(".rv, .vs, [data-stagger]"); - if (RM || !("IntersectionObserver" in window)) els.forEach((e) => e.classList.add("in")); - else { - const io = new IntersectionObserver((list) => list.forEach((e) => { - if (!e.isIntersecting) return; - io.unobserve(e.target); - e.target.classList.add("in"); - }), { threshold: 0.15 }); - els.forEach((e) => io.observe(e)); - } - - /* ---------- The Ladder: drag the fader, tap the track, or use the arrow keys ---------- */ - const thumb = $("#ldThumb"); - if (!thumb) return; - const track = $(".ld-track"), dig = $("#ldDig"), stepEl = $("#ldStep"), mnEl = $("#ldMn"); - const puck = $(".ld-puck"), inkOuter = $(".ld-ink"), inkInner = $(".ld-ink-in"), graph = $(".ld-graph"); - const STEPS = [[1, 9, "Start"], [7, 8, "Step 2 · month 7"], [13, 7, "Step 3 · month 13"], [25, 6, "Floor · month 25"]]; - const Y = [20, 73, 126, 180]; - const pad = (n) => String(n).padStart(2, "0"); - let month = 1, trackW = 0, graphW = 0, graphH = 0; - - function draw() { - const f = (month - 1) / 29; - let st = 0; STEPS.forEach((s, i) => { if (month >= s[0]) st = i; }); - dig.style.transform = `translate3d(0,${-25 * st}%,0)`; - stepEl.textContent = STEPS[st][2]; mnEl.textContent = pad(month); - thumb.style.transform = `translate3d(${(f * trackW).toFixed(1)}px,0,0)`; - thumb.setAttribute("aria-valuenow", String(month)); - thumb.setAttribute("aria-valuetext", `Month ${month}, $${STEPS[st][1]} a month. Planned.`); - inkOuter.style.transform = `translate3d(${(-(1 - f) * graphW).toFixed(1)}px,0,0)`; - inkInner.style.transform = `translate3d(${((1 - f) * graphW).toFixed(1)}px,0,0)`; - puck.style.transform = `translate3d(${(f * graphW).toFixed(1)}px,${((Y[st] / 200) * graphH).toFixed(1)}px,0)`; - } - // Sizes come from ResizeObservers, so drawing never forces a layout read. - new ResizeObserver((es) => { trackW = es[0].contentRect.width; draw(); }).observe(track); - new ResizeObserver((es) => { graphW = es[0].contentRect.width; graphH = es[0].contentRect.height; draw(); }).observe(graph); - - const set = (m) => { m = clamp(Math.round(m), 1, 30); if (m !== month) { month = m; draw(); } }; - let box = null, dragging = false; - const fromX = (x) => 1 + 29 * clamp((x - box.left) / box.width, 0, 1); - thumb.addEventListener("pointerdown", (e) => { dragging = true; box = track.getBoundingClientRect(); thumb.setPointerCapture(e.pointerId); e.preventDefault(); }); - thumb.addEventListener("pointermove", (e) => { if (dragging) set(fromX(e.clientX)); }); - const end = () => { dragging = false; }; - thumb.addEventListener("pointerup", end); thumb.addEventListener("pointercancel", end); - track.addEventListener("pointerdown", (e) => { box = track.getBoundingClientRect(); set(fromX(e.clientX)); }); - thumb.addEventListener("keydown", (e) => { - const d = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1, PageDown: -6, PageUp: 6 }[e.key]; - if (d) set(month + d); else if (e.key === "Home") set(1); else if (e.key === "End") set(30); else return; - e.preventDefault(); - }); - draw(); -})(); diff --git a/site/assets/img/og.jpg b/site/assets/img/og.jpg deleted file mode 100644 index a50b06a8..00000000 Binary files a/site/assets/img/og.jpg and /dev/null differ diff --git a/site/assets/img/og.png b/site/assets/img/og.png new file mode 100644 index 00000000..4233cf9d Binary files /dev/null and b/site/assets/img/og.png differ diff --git a/site/assets/opening.js b/site/assets/opening.js deleted file mode 100644 index c86db901..00000000 --- a/site/assets/opening.js +++ /dev/null @@ -1,25 +0,0 @@ -/* A first-visit flourish, not a loading screen. The page is always usable. */ -(() => { - "use strict"; - const root = document.documentElement; - let seen = false; - try { seen = localStorage.getItem("tm-intro-seen") === "1"; localStorage.setItem("tm-intro-seen", "1"); } catch (_) { /* Storage is optional. */ } - const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; - const light = root.dataset.tier === "low" || matchMedia("(pointer: coarse)").matches; - root.dataset.intro = !seen && !reduced ? "playing" : "done"; - root.dataset.introLight = String(light); - let timer; - const finish = () => { - if (root.dataset.intro !== "playing") return; - clearTimeout(timer); root.dataset.intro = "done"; - dispatchEvent(new Event("tm:intro-end")); - }; - document.addEventListener("click", e => { if (e.target.closest("#introEnter")) finish(); }); - addEventListener("keydown", e => { - if (e.key === "Escape" || (e.key === "Enter" && (e.target === document.body || e.target.id === "introEnter"))) finish(); - }); - addEventListener("scroll", () => { if (scrollY > 100) finish(); }, { passive: true }); - document.addEventListener("visibilitychange", () => { if (document.hidden) finish(); }); - // Also releases the Skip control if the device script is unavailable. - document.addEventListener("DOMContentLoaded", () => { timer = setTimeout(finish, light ? 1200 : 2900); }, { once: true }); -})(); diff --git a/site/assets/site.css b/site/assets/site.css index c9171e10..00713104 100644 --- a/site/assets/site.css +++ b/site/assets/site.css @@ -283,9 +283,13 @@ section[id], h1[id], h2[id] { scroll-margin-top: 84px; } .wl-status[data-state="error"] { color: var(--ink); } .wl-status[data-state="error"]::before { background: var(--broken-dot); border-radius: 1px; transform: rotate(45deg) scale(.86); } .wl-status[data-state="info"] { color: var(--ink-2); } -.wl-share { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; } -.wl-share p { flex: 1 1 220px; margin: 0; font-size: 15px; color: var(--ink-2); } -.wl-share input { flex: 1 1 100%; min-width: 0; height: 44px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--card); color: var(--ink); font: 15px var(--font-mono, monospace); } +.ref-card { display: grid; gap: 8px; max-width: 520px; margin-top: 14px; padding: 18px; border-radius: 14px; background: var(--card); box-shadow: inset 0 0 0 1px var(--line-2); text-align: left; } +.ref-card p { margin: 0; } +.ref-pos { font: 400 clamp(26px, 1rem + 1.6vw, 34px)/1.1 var(--font-display); color: var(--ink); } +.ref-sub { color: var(--ink-2); font-weight: 500; } +.ref-rule { color: var(--ink-2); font-size: 14.5px; line-height: 1.5; } +.ref-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; } +.ref-row input { flex: 1 1 220px; min-width: 0; height: 44px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--line-2); background: var(--paper); color: var(--ink); font: 13.5px var(--font-mono); } .wl-status[data-state="pending"] { color: var(--ink-3); } .wl-status[data-state="pending"]::before { animation: wl-pulse 1s ease-in-out infinite; } .wl-status a { color: inherit; font-weight: 600; text-decoration: underline; } diff --git a/site/assets/waitlist.js b/site/assets/waitlist.js index 8ab7a707..1431d99b 100644 --- a/site/assets/waitlist.js +++ b/site/assets/waitlist.js @@ -1,11 +1,14 @@ -/* Waitlist forms. Wires every form[data-waitlist] to POST /api/waitlist. - Shows success only when the server says ok. Never fakes it. */ +/* Waitlist forms. Wires every form[data-waitlist] to /api/waitlist. + Shows success only when the server says ok. Never fakes it. + After a signup: a personal link, the real place in line, and one share button. + Funnel counts (visit, share, copy) go to /api/event with no IP, cookie or identifier. */ (function () { "use strict"; var FOUNDER = "preston@unvibe.site"; + var CODE_RE = /^[a-z0-9]{10}$/; var MSG = { - ok: "You're on the list. We'll email you once, when it's ready.", + ok: "You're on the list.", sending: "Adding you…", email: "Please enter a valid email address.", consent: "Please tick the box so we can email you.", @@ -13,75 +16,64 @@ badRequest: "Something in the form went wrong. Reload the page and try again.", origin: "This form only works on the Time Machine site.", upstream: "We couldn't save that just now. Please try again in a minute.", - network: "We couldn't reach the server. Check your connection and try again." + network: "We couldn't reach the server. Check your connection and try again.", + soon: "The waitlist opens soon. It is not saving emails yet. Email " + FOUNDER + " and Preston adds you by hand." }; - // The API sits next to the assets folder, so blog pages and the home page hit the same URL. + // The API sits next to the assets folder, so blog pages and the home page hit the same URLs. var script = document.currentScript || document.querySelector('script[src*="waitlist.js"]'); - var endpoint = new URL("../api/waitlist", script ? script.src : location.href).href; + var base = script ? script.src : location.href; + var api = function (path) { return new URL("../api/" + path, base).href; }; var isFile = location.protocol === "file:"; - // A friend's link ends in ?via=share. Remember it for this visit so a signup on any page - // records it in the source path. Nothing else about the visitor is kept. - var VIA_KEY = "tm-via"; - var via = ""; - try { - if (new URLSearchParams(location.search).get("via") === "share") sessionStorage.setItem(VIA_KEY, "share"); - via = sessionStorage.getItem(VIA_KEY) === "share" ? "?via=share" : ""; - } catch (e) { via = /[?&]via=share(&|$)/.test(location.search) ? "?via=share" : ""; } - - var SHARE_URL = location.origin + "/?via=share"; - var SHARE_TEXT = "Time Machine saves your project before an AI agent acts and finds the last version that passes your tests."; - - // After a real signup only: one plain way to pass the link on. No rewards, no queue position. - function showShare(form, status) { - if (isFile || form.querySelector(".wl-share")) return; - var box = document.createElement("div"); - box.className = "wl-share"; - var text = document.createElement("p"); - text.textContent = "Know someone who lets an AI agent change their code? Send them the link."; - var btn = document.createElement("button"); - btn.type = "button"; - btn.className = "btn btn-warm btn-sm"; - var canShare = typeof navigator.share === "function"; - btn.textContent = canShare ? "Share the link" : "Copy link"; - btn.addEventListener("click", function () { - if (canShare) { - navigator.share({ title: "Time Machine", text: SHARE_TEXT, url: SHARE_URL }).catch(function () {}); - return; - } - var done = function () { btn.textContent = "Link copied"; }; - var manual = function () { - if (box.querySelector("input")) return; - var field = document.createElement("input"); - field.type = "text"; - field.readOnly = true; - field.value = SHARE_URL; - field.setAttribute("aria-label", "Link to share"); - box.appendChild(field); - field.select(); - }; - if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(SHARE_URL).then(done, manual); - else manual(); - }); - box.appendChild(text); - box.appendChild(btn); - status.parentNode.insertBefore(box, status.nextSibling); + function store(kind, key, value) { + try { + var s = kind === "local" ? localStorage : sessionStorage; + if (value === undefined) return s.getItem(key); + s.setItem(key, value); + } catch (e) { /* Storage is optional. */ } + return null; + } + + // A friend's link ends in ?ref=. Keep it for this visit so a signup on any page credits them. + var params = new URLSearchParams(location.search); + if (CODE_RE.test(params.get("ref") || "")) store("session", "tm-ref", params.get("ref")); + var ref = store("session", "tm-ref") || ""; + if (!CODE_RE.test(ref)) ref = ""; + + // Funnel counts. Respects Global Privacy Control and Do Not Track. Failures are silent by design. + var quiet = navigator.globalPrivacyControl === true || navigator.doNotTrack === "1"; + function count(type) { + if (isFile || quiet) return; + try { + fetch(api("event"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + keepalive: true, + body: JSON.stringify(ref ? { type: type, path: location.pathname, via: "ref" } : { type: type, path: location.pathname }) + }).catch(function () {}); + } catch (e) { /* Counting never breaks the page. */ } + } + if (!store("session", "tm-visit:" + location.pathname)) { + store("session", "tm-visit:" + location.pathname, "1"); + count("visit"); } - function say(status, form, state, text, withMail) { + // One span, so the status row (a flex box with a dot) lays the sentence out as one line of text. + function say(status, form, state, text) { status.textContent = ""; - if (withMail) { - var parts = text.split(FOUNDER); + var line = document.createElement("span"); + var parts = text.split(FOUNDER); + line.appendChild(document.createTextNode(parts[0])); + if (parts.length > 1) { var link = document.createElement("a"); link.href = "mailto:" + FOUNDER + "?subject=" + encodeURIComponent("Time Machine waitlist"); link.textContent = FOUNDER; - status.appendChild(document.createTextNode(parts[0])); - status.appendChild(link); - status.appendChild(document.createTextNode(parts[1] || "")); - } else { - status.textContent = text; + line.appendChild(link); + line.appendChild(document.createTextNode(parts[1])); } + status.appendChild(line); status.setAttribute("data-state", state); form.setAttribute("data-state", state); } @@ -94,13 +86,67 @@ return MSG.upstream; } - function wire(form) { + function el(tag, cls, text) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (text) n.textContent = text; + return n; + } + + // The card after a signup. Real numbers from the server only; nothing is shown when they are missing. + function showCard(form, info) { + var old = form.parentNode.querySelector(".ref-card"); + if (old) old.remove(); + var link = location.origin + "/?ref=" + info.code; + var card = el("div", "ref-card"); + if (typeof info.position === "number" && typeof info.total === "number") { + card.appendChild(el("p", "ref-pos", "You're #" + info.position + " of " + info.total + ".")); + } + if (typeof info.referrals === "number") { + card.appendChild(el("p", "ref-sub", info.referrals === 1 ? "1 friend joined with your link." : info.referrals + " friends joined with your link.")); + } + card.appendChild(el("p", "ref-rule", "Each friend who joins with your link moves you up. Order: most friends joined first, then earliest signup.")); + var row = el("div", "ref-row"); + var field = el("input"); + field.type = "text"; + field.readOnly = true; + field.value = link; + field.setAttribute("aria-label", "Your personal link"); + field.addEventListener("focus", function () { field.select(); }); + var copy = el("button", "btn btn-warm btn-sm", "Copy link"); + copy.type = "button"; + copy.addEventListener("click", function () { + count("copy"); + var done = function () { copy.textContent = "Copied"; }; + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(link).then(done, function () { field.focus(); }); + else field.focus(); + }); + row.appendChild(field); + row.appendChild(copy); + if (typeof navigator.share === "function") { + var share = el("button", "btn btn-ghost btn-sm", "Share"); + share.type = "button"; + share.addEventListener("click", function () { + count("share"); + navigator.share({ + title: "Time Machine", + text: "Time Machine saves your project before an AI agent acts and brings back the newest version that passes your tests.", + url: link + }).catch(function () {}); + }); + row.appendChild(share); + } + card.appendChild(row); + form.parentNode.insertBefore(card, form.nextSibling); + } + + function wire(form, first) { var email = form.querySelector('input[name="email"]'); var consent = form.querySelector('input[name="consent"]'); var honeypot = form.querySelector('input[name="company"]'); var button = form.querySelector('button[type="submit"], button:not([type])'); var status = form.querySelector("[data-waitlist-status]"); - if (!email || !consent || !status) return; + if (!email || !status) return; var busy = false; function fail(text, field) { @@ -112,7 +158,7 @@ } email.addEventListener("input", function () { email.removeAttribute("aria-invalid"); }); - consent.addEventListener("change", function () { consent.removeAttribute("aria-invalid"); }); + if (consent) consent.addEventListener("change", function () { consent.removeAttribute("aria-invalid"); }); form.addEventListener("submit", function (ev) { ev.preventDefault(); @@ -120,12 +166,10 @@ var value = email.value.trim(); if (!value || value.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) return fail(MSG.email, email); - if (!consent.checked) return fail(MSG.consent, consent); + // Forms with a checkbox need the tick. Forms without one ask through the button and the line under it. + if (consent && !consent.checked) return fail(MSG.consent, consent); - if (isFile) { - say(status, form, "info", "The waitlist works on the live site. For now, email " + FOUNDER + " and we'll add you by hand.", true); - return; - } + if (isFile) return say(status, form, "info", MSG.soon); busy = true; if (button) button.disabled = true; @@ -134,17 +178,14 @@ var controller = typeof AbortController === "function" ? new AbortController() : null; var timer = controller ? setTimeout(function () { controller.abort(); }, 15000) : null; + var body = { email: value, consent: true, source: location.pathname, company: honeypot ? honeypot.value : "" }; + if (ref) body.ref = ref; - fetch(endpoint, { + fetch(api("waitlist"), { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, credentials: "same-origin", - body: JSON.stringify({ - email: value, - consent: true, - source: location.pathname + via, - company: honeypot ? honeypot.value : "" - }), + body: JSON.stringify(body), signal: controller ? controller.signal : undefined }) .then(function (res) { @@ -157,9 +198,14 @@ if (r.status === 200 && r.data.ok === true) { say(status, form, "ok", MSG.ok); form.reset(); - showShare(form, status); + if (CODE_RE.test(r.data.code || "")) { + store("local", "tm-code", r.data.code); + showCard(form, r.data); + } else { + lookup(form); + } } else if (r.status === 503 && r.data.error === "not_configured") { - say(status, form, "info", "The waitlist isn't open yet. Email " + FOUNDER + " and we'll add you by hand.", true); + say(status, form, "info", MSG.soon); } else { var field = r.data.error === "invalid_email" ? email : r.data.error === "consent_required" ? consent : null; fail(messageFor(r.status, r.data.error), field); @@ -168,14 +214,42 @@ fail(MSG.network); }) .then(function () { - if (timer) clearTimeout(timer); + clearTimeout(timer); busy = false; if (button) button.disabled = false; form.removeAttribute("aria-busy"); }); }); + + if (first) lookup(form); } + // Someone who joined on this browser before sees their real place, fetched fresh. + function lookup(form) { + var code = store("local", "tm-code"); + if (isFile || !CODE_RE.test(code || "")) return; + fetch(api("waitlist") + "?code=" + encodeURIComponent(code), { headers: { Accept: "application/json" }, credentials: "same-origin" }) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (data) { + if (data && data.ok === true) { + data.code = code; + showCard(form, data); + } + }, function () {}); + } + + // Say up front when the list is not saving yet, so nobody is surprised after typing. var forms = document.querySelectorAll("form[data-waitlist]"); - for (var i = 0; i < forms.length; i++) wire(forms[i]); + for (var i = 0; i < forms.length; i++) wire(forms[i], i === 0); + if (!isFile && forms.length) { + fetch(api("health"), { headers: { Accept: "application/json" }, credentials: "same-origin" }) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (h) { + if (!h || h.waitlist !== false) return; + for (var j = 0; j < forms.length; j++) { + var s = forms[j].querySelector("[data-waitlist-status]"); + if (s && !s.getAttribute("data-state")) say(s, forms[j], "info", MSG.soon); + } + }, function () {}); + } })(); diff --git a/site/blog/a-save-point-should-prove-it-works.html b/site/blog/a-save-point-should-prove-it-works.html index a34de8d3..0e2264cc 100644 --- a/site/blog/a-save-point-should-prove-it-works.html +++ b/site/blog/a-save-point-should-prove-it-works.html @@ -12,10 +12,10 @@ - + - + diff --git a/site/blog/building-in-the-open.html b/site/blog/building-in-the-open.html index a4f49088..29f4cc8f 100644 --- a/site/blog/building-in-the-open.html +++ b/site/blog/building-in-the-open.html @@ -12,10 +12,10 @@ - + - + diff --git a/site/blog/index.html b/site/blog/index.html index 993daa5e..c6229445 100644 --- a/site/blog/index.html +++ b/site/blog/index.html @@ -12,10 +12,10 @@ - + - + diff --git a/site/blog/rewind-does-not-cover-the-shell.html b/site/blog/rewind-does-not-cover-the-shell.html index cb98d6be..3550e65b 100644 --- a/site/blog/rewind-does-not-cover-the-shell.html +++ b/site/blog/rewind-does-not-cover-the-shell.html @@ -12,10 +12,10 @@ - + - + diff --git a/site/blog/what-time-machine-does-not-do.html b/site/blog/what-time-machine-does-not-do.html index 1ef8da25..5d35076f 100644 --- a/site/blog/what-time-machine-does-not-do.html +++ b/site/blog/what-time-machine-does-not-do.html @@ -12,10 +12,10 @@ - + - + diff --git a/site/blog/why-a-safety-net-makes-people-braver.html b/site/blog/why-a-safety-net-makes-people-braver.html index 229091a3..4476b28e 100644 --- a/site/blog/why-a-safety-net-makes-people-braver.html +++ b/site/blog/why-a-safety-net-makes-people-braver.html @@ -12,10 +12,10 @@ - + - + diff --git a/site/company.html b/site/company.html index a0b1453e..9d1871bf 100644 --- a/site/company.html +++ b/site/company.html @@ -12,10 +12,10 @@ - + - + @@ -53,7 +53,7 @@

Build with
a wa
-
228automated tests. 226 passed locally on macOS. 2 Linux-only skips.
+
250automated tests. 248 pass on macOS. 2 run only on Linux.
17bugs found by two reviews, all fixed test first
7MCP tools on the same engine as the CLI
Localsave points. Known secrets filtered. Review what you capture.
@@ -126,7 +126,7 @@

Replit's agent wiped a production database.

  • MCP serverSeven tools for any agent that speaks MCP.Built
  • Store outside the projectIn ~/.timemachine, with a Git bundle, so save points survive the project folder being deleted.Built
  • Checks in a separate copyWith the network off when a sandbox exists, and BLOCKED when that cannot be enforced.Built
  • -
  • Tests228 automated tests, passing in CI on macOS and Linux with Node 22 and 24. Also tested on a real open source project, sindresorhus/slugify.Built
  • +
  • Tests250 automated tests, passing in CI on macOS and Linux with Node 22 and 24. Also tested on a real open source project, sindresorhus/slugify.Built
  • npm packagetimetm, command tm. Build from source until then.Not published yet
  • Mac appHistory, save point detail, Rewind and Compare, on the same engine. Not signed or shipped yet.In development
  • Action connector for Claude and ChatGPTVersion 2. Record what agents do in apps like Gmail and Slack, and reverse only what can honestly be reversed. Not available.Planned
  • @@ -171,7 +171,7 @@

    Every bug fix starts with a failing test.

    Gate 2 · Mac app

    diff --git a/site/how-it-works.html b/site/how-it-works.html index 6eb2b2ea..13da886e 100644 --- a/site/how-it-works.html +++ b/site/how-it-works.html @@ -12,10 +12,10 @@ - + - + diff --git a/site/index.html b/site/index.html index de153a3e..9f551517 100644 --- a/site/index.html +++ b/site/index.html @@ -3,28 +3,27 @@ -Time Machine. Rewind to what worked. - +Time Machine: get back the version that passed your tests + + - - + - - - + + + + - + + - - - @@ -34,11 +33,11 @@ Time Machine - Join the waitlist + Join the waitlist
    @@ -46,334 +45,119 @@
    -
    -

    A way back, with proof.

    -
    -
    - - -
    -

    Early. The tm command line tool works today.

    -

    Not on npm yet · Mac app in development

    -
    -
    + +
    -

    Rewind to what worked.

    -

    Your agent can break things. Time Machine proves which version still passes your tests and gets you back to it.

    - -

    Save before the change. Check in a separate copy. Bring back the version that passed.

    -
    -
    -
    -
    - - - - - -
    - - - - - - - - - -
    -
    - - -
    -
    -
    - -
    - - - - -
    -
    +

    Early access. The tm CLI works today. Not on npm yet.

    +

    Your AI agent broke it. Get back the version that passed your tests.

    +

    Time Machine saves your project before Claude Code acts. It runs your own tests on each save point in a separate copy, then brings back the newest one that passes. Your folder is never touched.

    +
    + +
    + +
    -
    -
    -

    cp-0006 · Added a dependency · not checked yet

    -
    -

    Synthetic demo. Turn the dial, then Check. Verdicts replay a recorded tm demo run.

    -
    + +

    +

    Emails only about Time Machine. Remove yourself any time. Privacy (draft).

    + + See it run
    -
    -
    -
    228automated tests. 226 pass on macOS. 2 run only on Linux. Both pass in CI.
    -
    7MCP tools. Agents that speak MCP can save, check and find the good one.
    -
    OffThe network, while your own check runs in a separate copy.
    -
    LocalSave points stay on your machine. Optional Ask sends metadata only after project consent.
    -
    -
    -
    - -
    -

    A normal morning with an agent

    It worked
    at 10:42.

    Then the agent kept going.

    See the proof
    -
    +
    +
    $ tm find-good
    +  cp-0006   FAIL   The check failed with exit code 1.
    +  cp-0005   FAIL   The check failed with exit code 1.
    +  cp-0004   FAIL   The check failed with exit code 1.
    +  cp-0003   PASS   The check passed.
    +PASS  cp-0003 is the newest version that works.
     
    -
    -
    -
    -

    01  The problem

    -

    Undo goes back one edit. Then what?

    -

    By the time you run your tests, the working version is several steps back. Some of those steps were shell commands.

    -
    -
    -
    -

    "Does not track files modified by Bash commands."

    - -
    -
    -
      -
    1. Dark mode looks right. Saved.cp-0003
    2. -
    3. You: "Fix the lint warnings."
    4. -
    5. Agent: fixed lint warningscp-0004
    6. -
    7. Changed currencycp-0005
    8. -
    9. Added a dependencycp-0006
    10. -
    11. You run npm test. Exit code 1.
    12. -
    -

    Dramatized on a synthetic demo project. Save point IDs and verdicts come from a real tm demo run. Times other than 10:42 and 10:51 are illustrative.

    -
    -
    -
    -

    Claude Code rewindBuilt in checkpoints

    Edits made by its file editing tools. Not Bash commands
    -

    Time Machinetm, today

    Your code, uncommitted work and the files Git sees at each save point, plus a check that proves it runs
    +$ tm recover cp-0003 +Recovery ready for cp-0003. Nothing was pushed, +merged or deployed. Your folder and branch +were not touched. + Branch tm/recover/cp-0003 + Saved first cp-0007, your state before this recovery +Not restored + Secret values in .env: PORT, STRIPE_SECRET_KEY + Databases, deployed versions and external services
    +
    Real output of tm demo on a synthetic project, 17 Oct 2026. Line breaks added, paths removed.
    +
    -

    Recognized secrets are filtered. Screening cannot recognize every arbitrary secret. Databases and deployed versions are not captured.

    -
    -
    -
    -

    02  The proof · tm find-good

    -

    Broken. Broken. Broken. Works.

    -

    Time Machine rebuilds each save point in a separate copy, turns the network off, and runs your own check. Newest first, until one passes. Your folder is never touched.

    +
    +
    +
    +

    Why

    +

    Built-in rewind skips the shell.

    -
    -
      -
    1. Safe copy · Network off

      Added a dependency

      cp-0006 · 11:12

      Broken

      The check failed with exit code 1.

    2. -
    3. Safe copy · Network off

      Changed currency

      cp-0005 · 11:04

      Broken

      The check failed with exit code 1.

    4. -
    5. Safe copy · Network off

      Agent: fixed lint warnings

      cp-0004 · 10:51

      Broken

      The check failed with exit code 1.

    6. -
    7. Safe copy · Network off

      Added dark mode

      cp-0003 · 10:42

      Works

      The check passed.

    8. -
    -
    -
    $ tm find-good
    -  cp-0006   FAIL       The check failed with exit code 1.
    -  cp-0005   FAIL       The check failed with exit code 1.
    -  cp-0004   FAIL       The check failed with exit code 1.
    -  cp-0003   PASS       The check passed.
    -PASS  cp-0003 is the newest version that works.
    -Next: tm recover cp-0003
    -
    -$ tm recover cp-0003
    -Recovery ready for cp-0003. Nothing was pushed, merged or deployed. Your folder and branch were not touched.
    -  Branch          tm/recover/cp-0003
    -  Saved first     cp-0007, your state before this recovery
    -Not restored
    -  Secret values in .env: PORT, STRIPE_SECRET_KEY
    -  Databases, deployed versions and external services are not captured
    -Manual steps
    -  Set PORT in .env in the recovery copy.
    -  Set STRIPE_SECRET_KEY in .env in the recovery copy.
    -

    Exact text from a real tm demo run on the synthetic Marigold Tips project, shortened. Every save point and report says what was not captured.

    -
    +
    +
    +

    "Checkpointing does not track files modified by Bash commands."

    + +
    +

    So when an agent runs rm, mv or sed -i, that rewind cannot bring the files back. Time Machine saves before risky shell commands, outside your project folder.

    -
    -

    03  How it works

    -

    Save. Check. Bring back.

    -

    Three steps. The CLI, hooks and Mac app share one engine.

    -
    +

    How it works

    +

    Save. Check. Bring back.

      -
    1. Save

      Claude Code hooks save before prompts and risky commands. Save points live outside your project folder.

      -
      $ tm checkpoint -m "Added dark mode"
      -Saved cp-0003  "Added dark mode"
      -Not checked yet.
    2. -
    3. Check

      Your own test runs in a separate copy, with the network off. One verdict per save point.

      -
      $ tm verify cp-0004
      -FAIL       cp-0004 is broken (host mode, network off via sandbox-exec, 0.3 s)
    4. -
    5. Bring back

      A recovery copy on a new branch. Your folder stays untouched. Nothing is pushed or deployed.

      -
      $ tm recover cp-0003
      -  Branch       tm/recover/cp-0003
      -  Saved first  cp-0007
    6. +
    7. Save

      Claude Code hooks save before each prompt and before risky commands. Save points live outside your project, with a Git bundle.

    8. +
    9. Check

      Each save point is rebuilt in a separate Git worktree and your own check runs, for example npm test. One verdict: PASS or FAIL.

    10. +
    11. Bring back

      The newest save point that passes comes back on a new branch. Nothing is pushed, merged or deployed.

    - Every command, verdict and exit code +

    What does not come back: secret values, databases, deployed versions and external services. Every report lists them.

    -
    +
    -
    -

    04  Agents

    -

    Works with Claude Code. More are planned.

    -

    Claude Code hooks are built and tested. Any agent that speaks MCP can call the tools. Hook kits for other agents are planned, not built.

    -
    -
      -
    • Claude CodeBuiltHooks save before each prompt and before risky shell commands, and check at the end of each turn.
    • -
    • Any agent that speaks MCPBuilt7 tools: checkpoint, timeline, diff, verify, find_good, recover and log. The agent calls them when it chooses, so saving is not automatic.
    • -
    • CursorPlannedIts hooks run before a prompt, before a shell command and when a turn stops. A Time Machine kit is not built yet.
    • -
    • Codex CLIPlannedIts hooks have the same three moments. A Time Machine kit is not built yet.
    • -
    • Gemini CLIPlannedIts hooks have the same three moments. A Time Machine kit is not built yet.
    • -
    • The Mac appIn developmentThe same engine, with a timeline you can see. Not signed or shipped yet.
    • +

      Check the work

      +

      Built in the open.

      +
        +
      • 250 automated testsRun in CI on macOS and Linux, Node 22 and 24. See the runs
      • +
      • MIT licenseThe engine, the CLI and the Mac app source. Read the code
      • +
      • Claude Code todayHooks are built and tested. Any MCP agent can call the 7 tools. Cursor, Codex CLI and Gemini CLI kits are planned.
      • +
      • Mac app in developmentSame engine, with a timeline you can see. Not signed or shipped yet.
    -
    -
    -
    -

    05  The Mac app In development

    -

    The same engine. A timeline you can see.

    -

    Every save point with its verdict, what came back and what did not. One button to go back to the last version that works. In development, not signed or shipped.

    -
    -
    -
    -
    - - - - The Time Machine Mac app with demo data. The project marigold-tips is Broken since 5:36 PM. The last version that works is from 4:23 PM, add dark mode. Six save points, newest first: one Broken, two Not checked yet, three Works. - -
    -
    History. In development, not signed or shipped. Browser preview with demo data.
    -
    -
    -
    - - - - The Rewind review screen with demo data. The 4:24 PM version works. What comes back: code and 2 files outside Git. What does not: secret values, of which only the names are kept, and databases, deployed versions and external services. - -
    -
    Rewind, review step. Same labels. Not available to download.
    -
    -
    -
    -

    Engine built, screens in development

    -
      -
    • Ask about your historyIn developmentAsk "When did it last work?" and get a short answer that names the save points. Off by default, per project. When you turn it on, it uses Google's Gemini API with your own key.
    • -
    • Vercel deploys on your timelineIn developmentSee which save point is live. Read only: it never deploys or rolls back.
    • -
    -
    -
    -
    - -
    -
    -
    -

    06  Pricing Planned. Nothing is for sale yet.

    -

    The Ladder.

    -

    Pro is planned at $9 a month and gets cheaper the longer you stay: $8 from month 7, $7 from month 13, and a $6 floor from month 25. Slide the months.

    -
      -
    • Restores are never paywalled. Getting back to a version that works never needs a paid plan.
    • -
    • The CLI is free.
    • -
    • Students pay half of every step.
    • -
    -
    -
    -
    -
    -
    - per month, planned - - Start -
    - -
    -
    -
    -
    -
    month01
    -
    -
    - -

    Planned. Nothing is for sale yet. Prices can change before launch. Restores are never paywalled.

    -
    -
    -
    -
    -
    -
    -
    -
    -

    07  Questions Draft

    -

    Short, honest answers.

    -
    -
    -
    Does it replace Git?

    No. It works next to Git. Save points live outside your project folder, and a recovery is a new branch you can inspect before you merge it yourself.

    -
    Does it undo database changes?

    No. Databases, deployed versions and external services are not captured. Every recovery report says so.

    -
    Does it store my secrets?

    Config records key names and fingerprints, not secret values. Known secret patterns are filtered from captured files and logs. Screening cannot recognize every arbitrary secret. Review what you capture.

    -
    Does it push or deploy?

    No. Nothing is pushed, merged or deployed. Your folder and your branch are not touched.

    -
    Does it work without Claude Code?

    Yes, through the MCP server or by hand with the tm commands. Automatic hooks exist for Claude Code today. Kits for Cursor, Codex CLI and Gemini CLI are planned.

    -
    Is it on npm?

    Not yet. Join the waitlist for one email when it is published.

    -
    Is the Mac app out?

    Not yet. It is in development and not signed or shipped. The screenshots above are a browser preview with demo data.

    -
    What does it cost?

    Nothing yet, because nothing is for sale. The planned price is The Ladder, above.

    +
    +
    +

    Questions

    +

    Short answers.

    +
    +
    +
    Does it replace Git?

    No. It works next to Git. A recovery is a new branch you can inspect before you merge it yourself.

    +
    Does it store my secrets?

    No values, only key names. Known secret patterns are filtered from captured files and logs. Screening cannot recognize every secret, so review what you capture.

    +
    Does it undo database changes or deploys?

    No. Databases, deployed versions and external services are not captured. Every report says so.

    +
    When can I use it?

    The CLI works today from source. It is not on npm yet. Waitlist members get one email when it is, and when the Mac app is ready.

    +
    How does the waitlist order work?

    After you join you get a personal link. Each friend who joins with it moves you up. Order: most friends joined first, then earliest signup. That is the whole rule.

    +
    What will it cost?

    Nothing is for sale. The CLI will be free and MIT licensed. Restores will never be behind a paywall.

    -
    -
    -
    -

    08  From the founder Draft

    -
    -

    Coding agents are fast. Sometimes they keep going after the thing already worked, and the version that worked is gone.

    -

    I'm building Time Machine so that version is never just a memory. It saves, it checks, and it tells you plainly what it could not bring back.

    -

    Preston · solo founder · preston@unvibe.site

    -

    Draft letter. Preston will rewrite it in his own words.

    +
    +
    +

    Build with a way back.

    +
    + +
    + +
    -
    -
    -

    Build with a way back.

    -

    One email when the CLI is on npm and the Mac app is ready. Nothing else.

    - -
    - -
    - - -
    -
    - - -

    -

    We store your email and the page you joined from. See the privacy policy (draft).

    - -
    + +

    +

    Built by Preston, a solo student founder. preston@unvibe.site

    +
    @@ -398,9 +182,6 @@

    Build with a way back.

    - - - diff --git a/site/privacy.html b/site/privacy.html index c4c835fa..b0a424c3 100644 --- a/site/privacy.html +++ b/site/privacy.html @@ -11,7 +11,7 @@ - + @@ -63,7 +63,7 @@

    2. What we collect

    Telemetry is off by default. If you turn it on, it sends event names, durations, verdict codes, app version and OS version. It never sends code, file names, paths, commit messages, branch names, setting names or values, or anything from your check output. You can turn it off at any time.

    Waitlist Draft

    -

    Draft, pending lawyer review. When you join the waitlist, we store four things: your email address, that you ticked the consent box, the page you signed up from, and the time. If you arrived through a link someone shared, the page ends in ?via=share. We do not record who shared it. They are stored in Supabase, written by a function hosted on Vercel. We do not store your IP address. To have your entry deleted, email preston@unvibe.site.

    +

    Draft, pending lawyer review. When you join the waitlist, we store six things: your email address, that you ticked the consent box, the page you signed up from, the time, your own referral code, and the referral code that brought you, if you came through someone's link. That last code tells us who referred you. We also count page visits, link shares and link copies. Each count stores only the kind of event, the page, whether the visit came through a referral link, and the time. Counts hold no email and no referral code. We do not store your IP address or your user agent (browser details), and none of this uses cookies. Everything is stored in Supabase, written by a function hosted on Vercel. To have your entry deleted, email preston@unvibe.site.

    3. What we never do

      diff --git a/site/robots.txt b/site/robots.txt new file mode 100644 index 00000000..21b73d5e --- /dev/null +++ b/site/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Allow: / +Disallow: /api/ + +Sitemap: https://time-machine-site.vercel.app/sitemap.xml diff --git a/site/security.html b/site/security.html index c85b7e87..6fe84e70 100644 --- a/site/security.html +++ b/site/security.html @@ -12,10 +12,10 @@ - + - + diff --git a/site/sitemap.xml b/site/sitemap.xml new file mode 100644 index 00000000..09cfede4 --- /dev/null +++ b/site/sitemap.xml @@ -0,0 +1,14 @@ + + + https://time-machine-site.vercel.app/ + https://time-machine-site.vercel.app/how-it-works + https://time-machine-site.vercel.app/story + https://time-machine-site.vercel.app/security + https://time-machine-site.vercel.app/company + https://time-machine-site.vercel.app/blog + https://time-machine-site.vercel.app/blog/rewind-does-not-cover-the-shell + https://time-machine-site.vercel.app/blog/a-save-point-should-prove-it-works + https://time-machine-site.vercel.app/blog/what-time-machine-does-not-do + https://time-machine-site.vercel.app/blog/why-a-safety-net-makes-people-braver + https://time-machine-site.vercel.app/blog/building-in-the-open + diff --git a/site/story.html b/site/story.html index 0c3d701e..81257c3b 100644 --- a/site/story.html +++ b/site/story.html @@ -12,10 +12,10 @@ - + - + @@ -282,8 +282,8 @@

      What works today.
      What doesn't

      Planned

      The Ladder
      Pricing. Nothing is for sale yet.
      -

      228

      -

      automated tests. 226 pass on macOS. 2 run only on Linux. Both pass in CI.

      +

      250

      +

      automated tests. 248 pass on macOS. 2 run only on Linux. Both pass in CI.

    diff --git a/site/terms.html b/site/terms.html index 14fc9c11..eb1eeb1d 100644 --- a/site/terms.html +++ b/site/terms.html @@ -11,7 +11,7 @@ - + diff --git a/supabase/README.md b/supabase/README.md index 60030d50..e8a572a2 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -1,16 +1,20 @@ # Supabase setup for the waitlist -The site has one form that saves emails: the waitlist. It posts to `/api/waitlist`, a small Vercel function in `site/api/`. That function writes to one Supabase table. Nothing else in the site uses Supabase. +The site has one form that saves emails: the waitlist. It posts to `/api/waitlist`, a small Vercel function in `site/api/`. `GET /api/waitlist?code=` looks up a person's place in the list. `POST /api/event` counts page visits, shares and link copies. These functions are the only things in the site that use Supabase. + +For a click-by-click guide with the full SQL in one block, see `docs/launch/SUPABASE_SETUP.md`. Until the two settings below are in Vercel, the form says the waitlist isn't open yet and asks people to email the founder. It never pretends to save. ## What gets stored -One row per person: the email, the consent tick (always true), the page they signed up from, and the time. No IP address. No browser details. Repeat signups with the same email are ignored, so each person is stored once. +One row per person: the email, the consent tick (always true), the page they signed up from, the time, their own referral code, and the referral code that brought them (if any). No IP address. No browser details. Repeat signups with the same email are ignored, so each person is stored once. + +The `funnel_events` table holds one row per visit, share or link copy: the event type, the page path, whether the visit came through a referral link, and the time. No email, no code, no IP address, no browser details. The `funnel_daily` view adds these up per day, next to signups. -Nobody can read or write the table with the public key. Row level security is on with no policies, and the `anon` and `authenticated` roles have no access. Only the service role key can insert. That key lives in Vercel and never reaches the browser. +Nobody can read or write the tables, the view or the functions with the public key. Row level security is on with no policies, and the `anon` and `authenticated` roles have no access. Only the service role key can use them. That key lives in Vercel and never reaches the browser. -## 1. Create the table +## 1. Create the tables Pick one way. @@ -18,8 +22,8 @@ Pick one way. 1. Open your Supabase project, or create one called `time-machine`. 2. Go to SQL Editor and start a new query. -3. Paste all of `supabase/migrations/0001_waitlist.sql` and press Run. -4. Go to Table Editor. You should see a `waitlist` table with no rows. +3. Paste all of `supabase/migrations/0001_waitlist.sql`, then all of `supabase/migrations/0002_referrals_and_funnel.sql`, and press Run. +4. Go to Table Editor. You should see `waitlist` and `funnel_events` tables with no rows. **Supabase CLI** @@ -27,7 +31,7 @@ Pick one way. 2. From the repo root, run `supabase link --project-ref `. 3. Run `supabase db push`. It applies everything in `supabase/migrations/`. -The file is safe to run twice. +The files are safe to run twice. ## 2. Add two settings to Vercel diff --git a/supabase/migrations/0002_referrals_and_funnel.sql b/supabase/migrations/0002_referrals_and_funnel.sql new file mode 100644 index 00000000..4d31db8e --- /dev/null +++ b/supabase/migrations/0002_referrals_and_funnel.sql @@ -0,0 +1,166 @@ +-- Time Machine waitlist: referral codes, list position, and anonymous funnel counts. +-- Needs 0001_waitlist.sql first. Safe to run twice. +-- Adds to each signup: their own referral code, and the code (if any) that brought them. +-- Adds a funnel_events table that holds only an event type, a page path, a via-referral flag and the time. +-- No IP addresses, no user agents, no cookies, no emails or codes in funnel_events. + +-- A fresh code: 10 characters from a to z and 0 to 9, from random bytes. +create or replace function public.waitlist_new_code() +returns text +language sql +volatile +set search_path = '' +as $$ + select string_agg( + substr('abcdefghijklmnopqrstuvwxyz0123456789', 1 + get_byte(uuid_send(gen_random_uuid()), 0) % 36, 1), + '' + ) + from generate_series(1, 10); +$$; + +-- Existing rows get a code when the column is added. On a second run the whole line is skipped. +alter table public.waitlist + add column if not exists ref_code text not null default public.waitlist_new_code() + constraint waitlist_ref_code_key unique + constraint waitlist_ref_code_format check (ref_code ~ '^[a-z0-9]{10}$'); + +-- The code that brought this person, or null. Deleting the referrer clears it. +alter table public.waitlist + add column if not exists referred_by text + constraint waitlist_referred_by_fkey references public.waitlist (ref_code) on delete set null; + +create index if not exists waitlist_referred_by_idx on public.waitlist (referred_by); + +comment on column public.waitlist.ref_code is 'This person''s own referral code, made in SQL.'; +comment on column public.waitlist.referred_by is 'The referral code that brought this person, or null.'; + +-- Place in the list for one code. +-- Order rule, shown on the site: more friends who joined with your link first, then earlier join time first. +-- Position is 1 plus the number of entries ahead under that rule. No row back means the code is unknown. +create or replace function public.waitlist_status(p_code text) +returns table ("position" bigint, referrals bigint, total bigint) +language sql +stable +set search_path = '' +as $$ + with refs as ( + select w.referred_by as code, count(*) as n + from public.waitlist w + where w.referred_by is not null + group by w.referred_by + ), + entries as ( + select w.ref_code, w.created_at, coalesce(r.n, 0) as referrals + from public.waitlist w + left join refs r on r.code = w.ref_code + ) + select + 1 + ( + select count(*) + from entries e + where e.referrals > me.referrals + or (e.referrals = me.referrals and e.created_at < me.created_at) + ), + me.referrals, + (select count(*) from public.waitlist) + from entries me + where me.ref_code = p_code; +$$; + +-- Join the list. Returns one row (code, position, total) for a new signup. +-- Returns no rows when the email is already on the list, and changes nothing. +-- A ref that is badly shaped or matches no one is ignored. Your own code cannot match, +-- because your code does not exist until your row is added. +create or replace function public.waitlist_join(p_email text, p_source text default null, p_ref text default null) +returns table (code text, "position" bigint, total bigint) +language plpgsql +volatile +set search_path = '' +as $$ +declare + v_ref text; + v_code text; +begin + select w.ref_code into v_ref + from public.waitlist w + where p_ref ~ '^[a-z0-9]{10}$' and w.ref_code = p_ref; + + insert into public.waitlist (email, consent, source, referred_by) + values (p_email, true, p_source, v_ref) + on conflict (email_normalized) do nothing + returning ref_code into v_code; + + if v_code is null then + return; + end if; + + return query + select v_code, s."position", s.total + from public.waitlist_status(v_code) s; +end; +$$; + +-- Anonymous funnel counts. +create table if not exists public.funnel_events ( + id uuid primary key default gen_random_uuid(), + type text not null check (type in ('visit', 'share', 'copy')), + path text not null check (char_length(path) between 1 and 200 and left(path, 1) = '/'), + via_ref boolean not null default false, + created_at timestamptz not null default now() +); + +comment on table public.funnel_events is + 'Anonymous page visits, shares and link copies. Written only by the site API with the service role key.'; + +-- Signups and funnel counts per day (UTC). +create or replace view public.funnel_daily +with (security_invoker = true) +as +with e as ( + select + (f.created_at at time zone 'utc')::date as day, + count(*) filter (where f.type = 'visit') as visits, + count(*) filter (where f.type = 'visit' and f.via_ref) as visits_via_ref, + count(*) filter (where f.type = 'share') as shares, + count(*) filter (where f.type = 'copy') as copies + from public.funnel_events f + group by 1 +), +s as ( + select + (w.created_at at time zone 'utc')::date as day, + count(*) as signups, + count(*) filter (where w.referred_by is not null) as signups_via_ref + from public.waitlist w + group by 1 +) +select + coalesce(e.day, s.day) as day, + coalesce(e.visits, 0) as visits, + coalesce(e.visits_via_ref, 0) as visits_via_ref, + coalesce(s.signups, 0) as signups, + coalesce(s.signups_via_ref, 0) as signups_via_ref, + coalesce(e.shares, 0) as shares, + coalesce(e.copies, 0) as copies +from e +full join s on s.day = e.day +order by 1 desc; + +-- Row level security on, and no policies at all. Only the service role can read or write. +alter table public.funnel_events enable row level security; + +revoke all on table public.funnel_events from public, anon, authenticated; +revoke all on table public.funnel_daily from public, anon, authenticated; +grant select, insert on table public.funnel_events to service_role; +grant select on table public.funnel_daily to service_role; + +-- Functions: Postgres lets everyone run new functions by default. Take that away. +revoke all on function public.waitlist_new_code() from public, anon, authenticated; +revoke all on function public.waitlist_status(text) from public, anon, authenticated; +revoke all on function public.waitlist_join(text, text, text) from public, anon, authenticated; +grant execute on function public.waitlist_new_code() to service_role; +grant execute on function public.waitlist_status(text) to service_role; +grant execute on function public.waitlist_join(text, text, text) to service_role; + +-- Ask the Supabase API to pick up the new functions and table now. +notify pgrst, 'reload schema';