diff --git a/README.md b/README.md
index b8ae05a9..176671d3 100644
--- a/README.md
+++ b/README.md
@@ -84,7 +84,7 @@ Timed on 17 Oct 2026 on a real repo, `sindresorhus/slugify`, with the packed `ti
| 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 | 261 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 | 267 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, personal referral links and anonymous funnel counts. Public at https://time-machine-site.vercel.app | Live; waitlist saves once Supabase is set up |
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index fbefc8f3..9f56d77c 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -84,6 +84,8 @@ The packaged `.app` launched through its own executable with isolated HOME, TM_H
- **Jump to… (`⌘K`):** one list you can type into: actions (Save now, Check it works, go back to the last working version, Compare, Agents, Storage, Settings), projects, and the 12 newest save points by time and title. Every typed word must match. Arrow keys and Enter, no mouse needed.
- **Inspector (`⌘I`, or ⋯):** the raw engine result of the last action and the same action as a `tm` command, with Copy. For the selected save point: the exact commit, its ref, the last check's command, exit code and log (with Show log), and the `tm` commands to check, open, use and compare it. Undo of a rewind has no `tm` command yet, so the Inspector says it is app only.
- **Look:** the Notebook direction from `design/APP_V3_DIRECTION.md`: warm paper and ink in light and dark, one mulberry accent for focus and questions (never a verdict), Newsreader headings with italic accents, Inter for text and Geist Mono for times and technical facts. The fonts ship in `src/renderer/fonts/` with their SIL OFL licences. Nothing loads from the network.
+- **Launch splash:** a black window with the Time Machine wordmark ("Machine" in italic orange) and a thin loading bar along the bottom edge, like the website's opening screen. It is drawn by `index.html` before any script runs and fades out over 300 ms once the settings, the projects and the first page are drawn, after at least 600 ms so it never flashes. The bar has no percentage. Any key or click skips it, and the key still reaches the app. It shows at launch only, never when you move between pages. With Reduce motion on it does not fade and has no minimum: it is there only while the app loads. The logic is in `src/renderer/splash.ts`.
+- **Motion:** a new page fades in and rises 8 px (220 ms). Sheets, Jump to… and the shortcut sheet scale and fade in, and they fade out when they close; the Rewind scene, the Inspector and toasts fade out too. What is closing never takes clicks, so nothing waits for it. A project's journal rows rise in one after another the first time the journal shows in a session, with the last row starting at most 300 ms after the first. Only opacity and transform move, so nothing shifts the layout. A redraw in the middle continues the movement instead of restarting it (`src/renderer/motion.ts`). The verdict stamp keeps its own moment. With Reduce motion on, none of this moves.
Every verdict has a shape and a word: Works, Broken, Couldn't check, Needs something, Check crashed, Not checked yet. Color only confirms. When a shortcut fires, its button shows its pressed state for about 120 ms.
@@ -116,6 +118,15 @@ The preview never opens a simulated overlay by itself. Use the banner's Show ove
- Browser preview, Playwright at 1280 by 800 and 960 by 640 (`/tmp/tm-pw/firstrun.mjs`): no covered buttons, no horizontal overflow, no page errors. Native runs: `/tmp/tm-pw/native-firstrun.mjs` and `/tmp/tm-pw/native-add.mjs`. Screenshots in `/tmp/tm-pw/app-firstrun/`.
- Not exercised: the macOS first open warning itself (the build was run from source) and a real Finder folder dialog. Dark mode was looked at on the welcome screen and onboarding step 2 only.
+## Splash and motion exercised checks
+
+18 October 2026, Apple silicon, synthetic demo data only. The packaged self-signed app (`package:local`) ran through its own executable with isolated HOME, TM_HOME, TMPDIR and userData.
+
+- Splash timing, measured from the window starting to load to the fade starting: 658 to 735 ms over six normal launches. From starting the process that was 0.9 to 1.3 s, and 2.8 s on the first launch after packaging. With Reduce motion emulated, the splash went with no fade at 654 to 678 ms, which is when the first page was drawn. So the 600 ms minimum adds at most about 80 ms. A key pressed at 180 ms started the fade at once.
+- Demo to recovery still works with the splash. Packaged app: Try the demo, leave the Rewind, Check it works (Broken), then Go back made `tm/recover/cp-0003`. Built from source: Try the demo, then the Go back key, made it in 2 clicks. Both times the demo folder kept the same branch, HEAD and status, and no renderer page errors were seen.
+- Browser preview (Chrome, 1280 by 800): page entrance and journal stagger played once, a closing Jump to… kept fading across a redraw and was removed after it, and with Reduce motion nothing moved. The browser preview has no splash.
+- Screenshots for the website, 1280 by 800 at 2x, light and dark: `/tmp/tm-pw/site-app-shots/`. Scripts: `/tmp/tm-pw/native-shots.mjs`, `/tmp/tm-pw/native-splash.mjs`, `/tmp/tm-pw/polish-preview.mjs`.
+
## V3 exercised checks
Local browser preview, 16 October 2026. These checks use synthetic demo data, not a real project or a live Gemini or Vercel connection.
diff --git a/apps/desktop/src/renderer/app.ts b/apps/desktop/src/renderer/app.ts
index 7566f114..cb09df09 100644
--- a/apps/desktop/src/renderer/app.ts
+++ b/apps/desktop/src/renderer/app.ts
@@ -14,6 +14,7 @@ import { renderDeployChips, renderUnlinkedDeploys } from "./deploys.js";
import { FEATURES_CHANGED } from "./feature-dom.js";
import { renderFeatureSettings } from "./feature-settings.js";
import { hookStepActions } from "./first-run.js";
+import { entranceDelay, LEAVE_MS, PAGE_IN_MS, rowDelay } from "./motion.js";
export interface StartOptions {
preview: boolean;
@@ -684,7 +685,7 @@ function checkSheet(): string {
const done = c.phase === "done" || !!c.error;
const lw = lastWorking();
const goBack = done && c.result?.verdict === "FAIL" && lw ? lw : null;
- return `
+ return `
Checking ${esc(p.name)} ${list}${result}
Your project folder is not touched. The check runs in a separate copy.
@@ -711,7 +712,7 @@ function rewind(): string {
? `
${tried.map((t) => { const e = entry(t.id); return `
${esc(e ? clock(e.createdAt) : t.id)} ${esc(e ? title(e) : "")} ${badge(t.verdict)}
`; }).join("")}
`
: "";
const pane = c ? tvRead(r, c) : { read: `
That save point is not in the history of ${esc(p.name)}.
`, key: tvKey("find", "Find the last working version", "enter", "↵"), alts: [] };
- return `
+ return `
Go back in ${esc(p.name)} ${rewindSteps(r)}
CloseEsc
${triedRows}${pane.read}
${pane.key}${pane.alts.join("")}
@@ -1033,7 +1034,7 @@ function timeView(): string {
sceneIds = pts.map((x) => x.id);
const still = r.finding || r.trying || r.recovering || r.step === "done";
const pane = tvRead(r, c);
- return `
+ return `
Go back ${esc(p.name)}
${rewindSteps(r)}
@@ -1107,6 +1108,8 @@ function compare(): string {
}
const LOADING = ' ';
+/** An empty project list: one sentence and the one thing to do about it. */
+const NO_PROJECTS = 'No projects yet. Add a project
';
/** Agents: one channel per coding agent, then Claude Code's connection in each project. */
function agentsScreen(): string {
@@ -1124,7 +1127,7 @@ function agentsScreen(): string {
const working = a.working ? `Claude Code is working now${a.workingSince ? `, since ${esc(clock(a.workingSince))}` : ""}.
` : "";
const btn = a.claudeCode === "not-connected" || a.claudeCode === "partial" ? `${a.claudeCode === "partial" ? "Add the missing lines" : "Connect"} ` : " ";
return `${esc(a.name)}
Claude Code: ${esc(words)}
${working}${st.settings.showGitDetails && a.settingsPath ? `
${esc(a.settingsPath)}
` : ""}
${btn}
`;
- }).join("") || 'Add a project first. '
+ }).join("") || NO_PROJECTS
: LOADING;
const stateWord: { [K in Channel["state"]]: string } = { working: "Working", idle: "Idle", partial: "Partly connected", "not-connected": "Not connected", planned: "Planned" };
const channel = (ch: Channel): string =>
@@ -1153,7 +1156,7 @@ function storageScreen(): string {
return `${esc(plural(c.result.files.length, "old check log"))}, ${esc(bytes(c.result.bytes))} Remove them `;
};
const rows = st.storage
- ? st.storage.map((r) => `${esc(r.name)}
${esc(bytes(r.bytes))} on this Mac
${st.settings.showGitDetails ? `
${esc(r.storeDir)}
` : ""}
${cleanRow(r.projectId)}Remove from app `).join("") || 'No projects yet. '
+ ? st.storage.map((r) => `${esc(r.name)}
${esc(bytes(r.bytes))} on this Mac
${st.settings.showGitDetails ? `
${esc(r.storeDir)}
` : ""}
${cleanRow(r.projectId)}Remove from app `).join("") || NO_PROJECTS
: LOADING;
return `
Storage Save points are stored on this Mac, outside your project folders, so deleting a project does not delete its history. This is not a full Mac backup.
@@ -1254,7 +1257,7 @@ function hookSheet(): string {
const h = st.hookSheet;
if (!h) return "";
const p = st.projects.find((x) => x.id === h.projectId);
- return `
+ return `
Connect Claude Code to ${esc(p?.name ?? "this project")}?
We'll add these lines to Claude Code's settings for this project and keep everything already there:
${hookLines(h.preview)}
@@ -1279,7 +1282,7 @@ function inspector(): string {
const block = (title: string, command: string | null, json: unknown): string => `
${esc(title)}
${command ? `
${esc(command)} Copy as command
` : '
No tm command for this. It is app only.
'}
${esc(JSON.stringify(json, null, 2))} `;
- return `
Inspector ${esc(keys("Mod+I"))} Close
+ return `Inspector ${esc(keys("Mod+I"))} Close
The raw engine result and the same action as a tm command. The app and the CLI call the same engine.
${rec ? block(`Last action: ${rec.title} · ${when(rec.at)}`, rec.command, rec.json) : '
No actions yet. Press Save now or Check it works.
'}
@@ -1316,7 +1319,7 @@ function savePointBlock(c: TimelineEntry, p: ProjectEntry): string {
function busyScrim(): string {
if (!st.demoBusy) return "";
- return `
Building the demo
+ return `
Building the demo
Making Marigold Tips, a small app with tests, then breaking it on purpose. The real engine checks its save points, newest first, to find the last one that works. Then you choose to go back to it.
This takes a few seconds. It happens in a new temporary folder.
`;
}
@@ -1462,7 +1465,7 @@ function paletteList(): string {
function paletteView(): string {
if (!st.palette) return "";
- return `
+ return `
${paletteList()}
@@ -1473,7 +1476,7 @@ function paletteView(): string {
function keysSheetView(): string {
if (!st.keysSheet) return "";
const row = (k: string, what: string): string => `
${k.split(" ").map((x) => `${esc(keys(x))} `).join("")} ${esc(what)}
`;
- return `
+ return `
Shortcuts CloseEsc
Everywhere
${row("Mod+K", "Jump to…")}${row("Mod+S", "Save now")}${row("Mod+Enter", "Check it works")}${row("Mod+Shift+R", "Go back to a working version")}${row("Mod+Shift+C", "Compare with now")}${row("Mod+↑", "Back to now")}${row("Mod+Z", "Undo the last rewind")}${row("Mod+J", "Show or hide Ask")}${row("Mod+N", "Add project")}${row("Mod+,", "Settings")}${row("Mod+I", "Inspector")}${row("?", "Show shortcuts")}
@@ -1580,6 +1583,62 @@ function cite(id: string): void {
}, 1400);
}
+/** The page on screen and when it appeared. The Rewind sits over History, so it is the same page. */
+let pageIn = { key: "", at: 0 };
+/** When each project's journal first showed its rows. Rows rise in only that first time. */
+const journalIn = new Map
();
+
+/** A new page fades in and rises; a project's journal rows rise in one after another, once. Redraws continue them. */
+function applyEntrances(scene: boolean): void {
+ const now = performance.now();
+ const key = `${st.screen === "rewind" ? "history" : st.screen}:${st.screen === "onboarding" ? st.onboarding.step : ""}:${PROJECT_SCREENS.includes(st.screen) ? (st.projectId ?? "") : ""}`;
+ if (key !== pageIn.key) pageIn = { key, at: now };
+ if (reducedMotion()) return;
+ const enter = (el: HTMLElement, cls: string, delay: number | null): void => {
+ if (delay === null) return;
+ el.classList.add(cls);
+ el.style.animationDelay = `${delay}ms`;
+ };
+ const page = root.querySelector(".main > .toolbar + *");
+ if (page) enter(page, "page-in", entranceDelay(0, now - pageIn.at, PAGE_IN_MS));
+ const rows = root.querySelectorAll("#list > *");
+ if (!st.projectId || scene || !rows.length) return;
+ const since = journalIn.get(st.projectId) ?? now;
+ journalIn.set(st.projectId, since);
+ rows.forEach((row, i) => enter(row, "row-in", rowDelay(i, now - since)));
+}
+
+/** A sheet, the Rewind, the Inspector or a toast that was on screen, and where it sat. */
+interface Leaving {
+ el: HTMLElement;
+ parent: ".main" | ".app";
+}
+
+/**
+ * What closed fades out. The old element stays for a moment over the new draw, inert and not
+ * clickable, so nothing waits for it. Web animations keep their time when a later redraw moves
+ * the element again, so a fade is never restarted or cut.
+ */
+function placeLeaving(old: Leaving[]): void {
+ for (const { el, parent } of old) {
+ const host = root.querySelector(parent);
+ if (!host) continue;
+ if (!el.classList.contains("leave")) {
+ if (reducedMotion() || root.querySelector(`[data-leave="${CSS.escape(el.dataset.leave ?? "")}"]:not(.leave)`)) continue;
+ el.classList.remove("enter");
+ el.classList.add("leave");
+ el.inert = true;
+ el.setAttribute("aria-hidden", "true");
+ const timing: KeyframeAnimationOptions = { duration: LEAVE_MS, easing: "cubic-bezier(.22,1,.36,1)", fill: "forwards" };
+ const sheet = el.querySelector(":scope > .sheet, :scope > .pal");
+ sheet?.animate([{ transform: "none" }, { transform: "scale(.97)" }], timing);
+ const to = el.classList.contains("drawer") ? "translateX(24px)" : el.classList.contains("toast") ? "translateY(6px)" : "none";
+ el.animate([{ opacity: 1, transform: "none" }, { opacity: 0, transform: to }], timing).onfinish = () => el.remove();
+ }
+ host.append(el);
+ }
+}
+
function render(): void {
const pageKey = `${st.screen}:${st.screen === "onboarding" ? st.onboarding.step : ""}`;
const pageScroll = pageKey === drawnPage ? (document.querySelector(".page")?.scrollTop ?? 0) : 0;
@@ -1592,6 +1651,7 @@ function render(): void {
for (const el of [m.el, ...m.el.querySelectorAll("*")]) if (el.scrollTop) scrolled.push([el, el.scrollTop]);
}
captureCarry();
+ const leaving: Leaving[] = [...root.querySelectorAll("[data-leave]")].map((el) => ({ el, parent: el.parentElement?.classList.contains("main") ? ".main" : ".app" }));
drawOnce = new Set();
const scene = st.screen === "rewind" && st.rewind?.mode === "scene";
const main =
@@ -1609,7 +1669,7 @@ function render(): void {
const appCls = right ? (right.includes('class="rcol detail') ? "has-detail" : "has-ask") : "";
root.innerHTML = `
${opts.preview ? `
Preview with demo data. Download the app to use it on your projects. ${(opts.previewActions ?? []).map((a, i) => `${esc(a.label)} `).join("")}
` : ""}
-
${scene ? sidebar().replace('
', '') : sidebar()}${toolbar()}${main}${st.check ? checkSheet() : ""}${hookSheet()}${busyScrim()} ${right}${scene ? timeView() : ""}${inspector()}${paletteView()}${keysSheetView()}${st.toast ? `${esc(st.toast)}
` : ""} `;
+ ${scene ? sidebar().replace('
', '') : sidebar()}${toolbar()}${main}${st.check ? checkSheet() : ""}${hookSheet()}${busyScrim()} ${right}${scene ? timeView() : ""}${inspector()}${paletteView()}${keysSheetView()}${st.toast ? `${esc(st.toast)}
` : ""} `;
seenOnce = drawOnce;
const page = document.querySelector(".page");
if (page) page.scrollTop = pageScroll;
@@ -1617,10 +1677,12 @@ function render(): void {
fitSessions();
applyCarry();
applyFlash();
+ applyEntrances(scene);
const tv = root.querySelector
(".tv");
const again = focus ? root.querySelector(focus) : null;
if (again && !(again instanceof HTMLButtonElement && again.disabled)) again.focus({ preventScroll: true });
else if (tv && !tv.contains(document.activeElement)) tv.focus({ preventScroll: true });
+ placeLeaving(leaving);
}
/** "These stay saved" sits on the session lane. A session it would cover ends before it, or hides when too little is left. */
diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html
index 70678d65..573b5273 100644
--- a/apps/desktop/src/renderer/index.html
+++ b/apps/desktop/src/renderer/index.html
@@ -9,6 +9,10 @@
+
diff --git a/apps/desktop/src/renderer/index.ts b/apps/desktop/src/renderer/index.ts
index 70fa30cf..540bd91b 100644
--- a/apps/desktop/src/renderer/index.ts
+++ b/apps/desktop/src/renderer/index.ts
@@ -1,6 +1,7 @@
// The app window. The bridge comes from the preload script.
import type { TmBridge } from "../shared/api.js";
import { start } from "./app.js";
+import { mountSplash } from "./splash.js";
declare global {
interface Window {
@@ -8,4 +9,7 @@ declare global {
}
}
-start(window.tm, { preview: false });
+// start() resolves once the settings, the projects and the first page are drawn, or it failed.
+// Either way the launch splash can go.
+const splash = mountSplash();
+void start(window.tm, { preview: false }).finally(() => splash.ready());
diff --git a/apps/desktop/src/renderer/motion.ts b/apps/desktop/src/renderer/motion.ts
new file mode 100644
index 00000000..44335e76
--- /dev/null
+++ b/apps/desktop/src/renderer/motion.ts
@@ -0,0 +1,28 @@
+// Entrance timing that survives redraws. The window redraws from state, often several times while
+// something is still entering, so an entrance carries on where it was instead of starting over.
+
+/** A new page fades in and rises 8 px over this long. */
+export const PAGE_IN_MS = 220;
+/** One journal row fades in and rises over this long. */
+export const ROW_IN_MS = 260;
+/** Journal rows start this far apart... */
+export const ROW_STAGGER_MS = 35;
+/** ...and the last one starts at most this long after the first, however long the journal is. */
+export const ROW_STAGGER_CAP_MS = 300;
+/** Sheets, the Rewind, the Inspector and toasts fade out over this long. */
+export const LEAVE_MS = 180;
+
+/**
+ * The animation delay that shows an entrance `elapsed` ms after it began, for an entrance that
+ * starts `start` ms in and runs `duration` ms. Negative means part of it already played. Null
+ * when it has finished, so the element is drawn plainly.
+ */
+export function entranceDelay(start: number, elapsed: number, duration: number): number | null {
+ const delay = start - elapsed;
+ return delay + duration > 0 ? delay : null;
+}
+
+/** The animation delay for journal row `index`, `elapsed` ms after the journal first showed. */
+export function rowDelay(index: number, elapsed: number): number | null {
+ return entranceDelay(Math.min(index * ROW_STAGGER_MS, ROW_STAGGER_CAP_MS), elapsed, ROW_IN_MS);
+}
diff --git a/apps/desktop/src/renderer/splash.ts b/apps/desktop/src/renderer/splash.ts
new file mode 100644
index 00000000..28dccbf4
--- /dev/null
+++ b/apps/desktop/src/renderer/splash.ts
@@ -0,0 +1,85 @@
+// The launch screen: a black stage with the wordmark and a thin loading bar, drawn by index.html
+// before any script runs. It stays while the window loads its first real data, at least
+// SPLASH_MIN_MS so it never flashes, then fades. Any key or click skips it. With Reduce motion it
+// does not fade and has no minimum: it goes the moment the data is there.
+
+/** Shortest time the splash is on screen, so a fast load does not flash it. */
+export const SPLASH_MIN_MS = 600;
+/** How long the fade out takes. */
+export const SPLASH_FADE_MS = 300;
+
+export type SplashPhase = "showing" | "fading" | "gone";
+
+/** What the splash needs from the window. The tests drive it with a fake clock. */
+export interface SplashHost {
+ now(): number;
+ reduced(): boolean;
+ later(fn: () => void, ms: number): void;
+ /** Start fading over `ms`. From here on the splash no longer takes clicks. */
+ fade(ms: number): void;
+ remove(): void;
+}
+
+export interface Splash {
+ /** The first real data is drawn. */
+ ready(): void;
+ /** A key or click: go now, without waiting for the minimum. */
+ skip(): void;
+ phase(): SplashPhase;
+}
+
+/** When the splash starts to fade, for a splash shown at `shownAt` whose data was ready at `readyAt`. */
+export function splashFadeAt(shownAt: number, readyAt: number, reduced: boolean): number {
+ return reduced ? readyAt : Math.max(readyAt, shownAt + SPLASH_MIN_MS);
+}
+
+export function splashController(host: SplashHost): Splash {
+ const shownAt = host.now();
+ let phase: SplashPhase = "showing";
+ const leave = (): void => {
+ if (phase !== "showing") return;
+ phase = "fading";
+ const ms = host.reduced() ? 0 : SPLASH_FADE_MS;
+ host.fade(ms);
+ const done = (): void => {
+ phase = "gone";
+ host.remove();
+ };
+ if (ms) host.later(done, ms);
+ else done();
+ };
+ return {
+ ready() {
+ if (phase !== "showing") return;
+ const now = host.now();
+ const wait = splashFadeAt(shownAt, now, host.reduced()) - now;
+ if (wait > 0) host.later(leave, wait);
+ else leave();
+ },
+ skip: leave,
+ phase: () => phase,
+ };
+}
+
+/** The splash in this window, or one that does nothing when the page has none (the browser preview). */
+export function mountSplash(): Splash {
+ const el = document.getElementById("splash");
+ if (!el) return { ready() {}, skip() {}, phase: () => "gone" };
+ const onKey = (): void => splash.skip();
+ const splash = splashController({
+ now: () => performance.now(),
+ reduced: () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches,
+ later: (fn, ms) => void setTimeout(fn, ms),
+ fade(ms) {
+ performance.mark("tm-splash-fade");
+ window.removeEventListener("keydown", onKey, true);
+ el.style.transitionDuration = `${ms}ms`;
+ el.classList.add("out");
+ },
+ remove: () => el.remove(),
+ });
+ // The key still reaches the window, so a shortcut pressed during the splash works.
+ window.addEventListener("keydown", onKey, true);
+ el.addEventListener("pointerdown", () => splash.skip());
+ return splash;
+}
diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css
index a73940d7..883fefec 100644
--- a/apps/desktop/src/renderer/styles.css
+++ b/apps/desktop/src/renderer/styles.css
@@ -58,7 +58,8 @@ html, body { height: 100%; }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--font); font-size: 13px; line-height: 1.45; -webkit-font-smoothing: antialiased; overflow: hidden; user-select: none; }
body.overlay-body { background: transparent; }
button, input, select { font: inherit; color: inherit; }
-:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 7px; }
+/* Focus rings follow each control's own corners, so a focused pill stays a pill. */
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.mono { font-family: var(--mono); font-size: 12px; }
.muted { color: var(--ink-3); }
.small { font-size: 12px; }
@@ -73,6 +74,30 @@ kbd { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); font-var
.hero-h, .page h1, .sheet h2, .sp-h, .tv-head, .cd-title, .welcome h1, .big-verdict { font-family: var(--serif); font-weight: 400; letter-spacing: -.02em; }
.hero-h em, .tv-head em, .page h1 em { font-style: italic; }
+/* Launch splash: a black stage, the wordmark and a thin loading bar along the bottom edge, like the
+ website's opening screen. It is drawn by index.html before any script runs, and splash.ts fades it
+ once the first page is drawn. The bar has no percentage: it only says the app is still loading. */
+.splash { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; background: #000; color: #f4efe6; transition: opacity 300ms var(--ease); cursor: default; }
+.splash.out { opacity: 0; pointer-events: none; }
+.splash-word { margin: 0 0 12px; font: 300 clamp(56px, 8.5vw, 120px)/.9 var(--serif); font-variation-settings: "opsz" 72; letter-spacing: -.03em; white-space: nowrap; }
+.splash-word span, .splash-word em { display: inline-block; animation: splash-rise .32s var(--ease) both; }
+.splash-word em { color: #ff791f; animation-delay: .06s; }
+@keyframes splash-rise { from { opacity: 0; transform: translateY(.12em); } }
+.splash-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255, 255, 255, .08); overflow: hidden; }
+.splash-bar i { position: absolute; top: 0; bottom: 0; left: 0; width: 34%; background: linear-gradient(90deg, rgba(209, 78, 11, 0), #d14e0b 30%, #ff963f); animation: splash-load 1.1s cubic-bezier(.65, 0, .35, 1) infinite; }
+@keyframes splash-load { from { transform: translateX(-100%); } to { transform: translateX(300%); } }
+@media (prefers-reduced-motion: reduce) { .splash-bar i { animation: none; width: 100%; opacity: .5; } }
+
+/* Arrivals. A new page fades in and rises 8 px; a project's journal rows rise in one after another,
+ the first time only. motion.ts sets the delays, so a redraw continues them instead of restarting. */
+.page-in { animation: page-in var(--dur) var(--ease) both; }
+@keyframes page-in { from { opacity: 0; transform: translateY(8px); } }
+.row-in { animation: row-in .26s var(--ease) both; }
+@keyframes row-in { from { opacity: 0; transform: translateY(6px); } }
+/* Something that closed fades out over the new draw. It takes no clicks, and nothing inside it replays. */
+.leave { pointer-events: none; }
+.leave * { animation: none !important; }
+
/* Shell */
#root { height: 100%; }
.shell { height: 100%; display: flex; flex-direction: column; }
@@ -115,8 +140,10 @@ kbd { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); font-var
.crumb > span[aria-hidden] { color: var(--ink-4); }
.cr-p { border: 0; background: none; padding: 2px 4px; margin-left: -4px; border-radius: var(--r-sm); font-weight: 500; color: var(--ink-2); cursor: pointer; overflow: hidden; text-overflow: ellipsis; }
.cr-p:hover { background: var(--sel); color: var(--ink); }
+.cr-p:active { background: var(--press); }
.tb-sp { flex: 1; -webkit-app-region: drag; }
.pill { display: inline-flex; align-items: center; gap: 8px; height: 26px; padding: 0 12px; border-radius: 999px; border: 0; background: var(--ink); color: var(--paper); font-size: 12px; font-weight: 500; cursor: pointer; white-space: nowrap; }
+.pill:hover { background: color-mix(in srgb, var(--ink) 86%, var(--paper)); }
.pill:active, .pill.kf { transform: scale(.97); }
/* The right column: Ask, or the open entry. When the window is narrow it does not squeeze the page:
@@ -218,6 +245,7 @@ kbd { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); font-var
.entry::before { content: ""; position: absolute; left: 72px; top: 0; bottom: 0; width: 1px; background: var(--line); }
.entry.first::before { top: 22px; }
.entry:hover { background: color-mix(in srgb, var(--sel) 60%, transparent); }
+.entry:active { background: var(--sel); transition-duration: .08s; }
.entry.on { background: var(--mist); }
.entry.lit { background: var(--accent-bg); }
.entry .when { font: 400 11.5px/24px var(--mono); color: var(--ink-3); text-align: right; padding-right: 10px; font-variant-numeric: tabular-nums; }
@@ -244,7 +272,8 @@ kbd { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); font-var
.fold .gl { display: grid; justify-items: center; padding-top: 7px; }
.fold .gl i { width: 5px; height: 5px; border-radius: 50%; background: var(--line-2); box-shadow: 0 0 0 3px var(--paper); position: relative; }
.fold .body { padding-left: 8px; }
-.fold:hover { color: var(--ink); }
+.fold:hover { color: var(--ink); background: color-mix(in srgb, var(--sel) 60%, transparent); }
+.fold:active { background: var(--sel); }
.broke-rule { display: grid; grid-template-columns: 62px 22px minmax(0, 1fr); align-items: center; margin: 4px 0; color: var(--fail-fg); font: 500 11px/1 var(--mono); letter-spacing: .02em; }
.broke-rule .line { grid-column: 2 / 4; height: 0; border-top: 1px dashed var(--fail-dot); position: relative; }
.broke-rule .line span { position: absolute; right: 0; top: -7px; padding-left: 8px; background: var(--paper); }
@@ -261,6 +290,7 @@ kbd { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); font-var
.mn-l { position: absolute; right: 0; top: calc(100% + 4px); z-index: 50; min-width: 220px; display: grid; padding: 5px; border-radius: 12px; background: var(--surface); box-shadow: var(--shadow-lg); }
.mn-l button { display: flex; align-items: center; gap: 10px; justify-content: space-between; padding: 7px 10px; border: 0; border-radius: 7px; background: none; text-align: left; cursor: pointer; font-size: 13px; color: var(--ink); white-space: nowrap; }
.mn-l button:hover:not([disabled]) { background: var(--sel); }
+.mn-l button:active:not([disabled]) { background: var(--press); }
.mn-l button[disabled] { opacity: .4; }
.kv { display: grid; gap: 6px; font-size: 12.5px; align-content: start; }
.kv .h { font-size: 11px; font-weight: 600; color: var(--ink-3); text-transform: uppercase; letter-spacing: .04em; }
@@ -300,6 +330,7 @@ kbd { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); font-var
.note.bad { background: var(--fail-bg); color: var(--fail-fg); box-shadow: none; }
.opt { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 10px 12px; border-radius: 10px; cursor: pointer; box-shadow: inset 0 0 0 1px var(--line); }
.opt.on { box-shadow: inset 0 0 0 1.5px var(--ink); }
+label.opt:hover { background: color-mix(in srgb, var(--sel) 60%, transparent); }
.opt input[type="radio"], .opt input[type="checkbox"] { margin: 2px 0 0; accent-color: var(--ink); }
.opt .t { font-weight: 500; }
.opt .d { font-size: 12px; color: var(--ink-3); }
@@ -327,10 +358,12 @@ select.field { font-family: var(--font); font-size: 12.5px; width: 100%; min-wid
.seg { justify-self: start; display: inline-flex; background: var(--card); border-radius: 8px; padding: 2px; font-size: 12px; }
.seg button { padding: 4px 12px; border-radius: 6px; color: var(--ink-3); border: 0; background: none; cursor: pointer; white-space: nowrap; }
.seg button:hover:not(.on) { color: var(--ink); }
+.seg button:active:not(.on) { background: var(--sel); }
.toggle .seg { flex: none; }
.seg button.on { background: var(--surface); color: var(--ink); box-shadow: 0 1px 2px rgba(28,24,16,.12); }
.switch { position: relative; width: 34px; height: 20px; border-radius: 999px; background: var(--ink-5); border: 0; cursor: pointer; flex: none; transition: background .2s; }
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: left .2s var(--ease); }
+.switch:hover { box-shadow: 0 0 0 3px var(--sel); }
.switch.on { background: var(--ink); }
.switch.on::after { left: 16px; }
:root[data-theme="dark"] .switch.on::after { background: #18181b; }
@@ -352,14 +385,16 @@ select.field { font-family: var(--font); font-size: 12.5px; width: 100%; min-wid
.ch-planned .amark { color: var(--ink-3); background: none; }
.hook-words { margin: 8px 0 0; padding-left: 18px; display: grid; gap: 2px; font-size: 12.5px; color: var(--ink-2); }
.hook-exact { margin-top: 8px; font-size: 12px; }
-.hook-exact summary { cursor: pointer; color: var(--ink-2); width: max-content; }
+.hook-exact summary { cursor: pointer; color: var(--ink-2); width: max-content; border-radius: 4px; }
+.hook-exact summary:hover { color: var(--ink); }
.hook-exact[open] summary { margin-bottom: 6px; }
.hook-exact .code { margin-top: 4px; }
/* Sheet, drawer, toast */
.scrim { position: absolute; inset: 0; z-index: 20; background: var(--veil); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); display: grid; place-items: center; padding: 24px; }
.scrim.enter { animation: fade .2s var(--ease); }
-.scrim.enter .sheet { animation: rise .22s var(--ease); }
+.scrim.enter .sheet { animation: pop .22s var(--ease); }
+@keyframes pop { from { transform: scale(.97); opacity: 0 } }
@keyframes fade { from { opacity: 0 } }
.sheet { width: min(560px, 100%); max-height: 100%; overflow: auto; border-radius: 16px; background: var(--surface); box-shadow: var(--shadow-lg); padding: 22px; display: grid; gap: 14px; }
.sheet.wide { width: min(720px, 100%); gap: 16px; padding: 24px; }
@@ -387,15 +422,17 @@ select.field { font-family: var(--font); font-size: 12.5px; width: 100%; min-wid
/* Jump to (⌘K) and the shortcut sheet */
.jump { display: inline-flex; align-items: center; gap: 18px; height: 30px; padding: 0 8px 0 12px; border: 0; border-radius: 8px; background: var(--card); color: var(--ink-3); font-size: 12.5px; cursor: pointer; white-space: nowrap; }
.jump:hover { color: var(--ink); box-shadow: inset 0 0 0 1px var(--line-2); }
+.jump:active { background: var(--press); }
.scrim.pal-scrim, .app > .scrim { z-index: 45; place-items: start center; padding-top: 12vh; }
.pal { width: min(620px, 100%); max-height: 70vh; display: flex; flex-direction: column; border-radius: 16px; background: var(--surface); box-shadow: var(--shadow-lg); overflow: hidden; }
-.scrim.enter .pal { animation: rise .2s var(--ease); }
+.scrim.enter .pal { animation: pop .2s var(--ease); }
.pal-q { flex: none; height: 54px; border: 0; border-bottom: 1px solid var(--line); background: none; padding: 0 20px; font-size: 16px; color: var(--ink); outline: none; user-select: text; }
.pal-q::placeholder { color: var(--ink-4); }
.pal-list { overflow: auto; padding: 6px; display: grid; align-content: start; }
.pal-g { padding: 10px 12px 4px; font-size: 11px; font-weight: 600; color: var(--ink-3); text-transform: uppercase; letter-spacing: .04em; }
.pal-i { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 8px 12px; border: 0; border-radius: 9px; background: none; text-align: left; cursor: pointer; color: var(--ink); font-size: 13.5px; }
.pal-i:hover { background: var(--sel); }
+.pal-i:active { background: var(--press); }
.pal-i.on { background: var(--card); box-shadow: inset 0 0 0 1px var(--line); }
.pal-l { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pal-h { font-size: 12px; color: var(--ink-3); white-space: nowrap; }
diff --git a/apps/desktop/test/motion.test.ts b/apps/desktop/test/motion.test.ts
new file mode 100644
index 00000000..d4873e53
--- /dev/null
+++ b/apps/desktop/test/motion.test.ts
@@ -0,0 +1,94 @@
+// The launch splash and the entrance timing. The splash runs on a fake clock, so the tests say
+// exactly when it fades and when it is gone. No window needed.
+import { describe, expect, test } from "vitest";
+import { entranceDelay, PAGE_IN_MS, ROW_IN_MS, ROW_STAGGER_CAP_MS, rowDelay } from "../src/renderer/motion.ts";
+import { SPLASH_FADE_MS, SPLASH_MIN_MS, splashController, splashFadeAt, type SplashHost } from "../src/renderer/splash.ts";
+
+/** A splash on a fake clock. `events` records when it started fading, for how long, and when it went. */
+function fakeSplash(reduced = false, shownAt = 100) {
+ let t = shownAt;
+ let timers: { at: number; fn: () => void }[] = [];
+ const events: string[] = [];
+ const host: SplashHost = {
+ now: () => t,
+ reduced: () => reduced,
+ later: (fn, ms) => void timers.push({ at: t + ms, fn }),
+ fade: (ms) => void events.push(`fade ${ms} at ${t}`),
+ remove: () => void events.push(`gone at ${t}`),
+ };
+ const splash = splashController(host);
+ const advance = (to: number): void => {
+ for (;;) {
+ const next = timers.filter((x) => x.at <= to).sort((a, b) => a.at - b.at)[0];
+ if (!next) break;
+ timers = timers.filter((x) => x !== next);
+ t = next.at;
+ next.fn();
+ }
+ t = to;
+ };
+ return { splash, events, advance };
+}
+
+describe("the launch splash", () => {
+ test("a fast load still shows it for the minimum, so it never flashes, then it fades", () => {
+ const { splash, events, advance } = fakeSplash();
+ advance(250);
+ splash.ready();
+ expect(splash.phase()).toBe("showing");
+ advance(100 + SPLASH_MIN_MS);
+ expect(events).toEqual([`fade ${SPLASH_FADE_MS} at ${100 + SPLASH_MIN_MS}`]);
+ expect(splash.phase()).toBe("fading");
+ advance(100 + SPLASH_MIN_MS + SPLASH_FADE_MS);
+ expect(events.at(-1)).toBe(`gone at ${100 + SPLASH_MIN_MS + SPLASH_FADE_MS}`);
+ expect(splash.phase()).toBe("gone");
+ });
+
+ test("a slow load adds no wait: it fades the moment the first page is drawn", () => {
+ const { splash, events, advance } = fakeSplash();
+ advance(2400);
+ splash.ready();
+ expect(events).toEqual([`fade ${SPLASH_FADE_MS} at 2400`]);
+ });
+
+ test("any key or click skips it at once, and the data arriving later changes nothing", () => {
+ const { splash, events, advance } = fakeSplash();
+ advance(180);
+ splash.skip();
+ expect(events).toEqual([`fade ${SPLASH_FADE_MS} at 180`]);
+ advance(400);
+ splash.ready();
+ splash.skip();
+ advance(5000);
+ expect(events).toEqual([`fade ${SPLASH_FADE_MS} at 180`, `gone at ${180 + SPLASH_FADE_MS}`]);
+ });
+
+ test("with Reduce motion it has no minimum and no fade: it is there only while loading", () => {
+ const { splash, events, advance } = fakeSplash(true);
+ advance(160);
+ splash.ready();
+ expect(events).toEqual(["fade 0 at 160", "gone at 160"]);
+ expect(splash.phase()).toBe("gone");
+ expect(splashFadeAt(100, 160, true)).toBe(160);
+ expect(splashFadeAt(100, 160, false)).toBe(100 + SPLASH_MIN_MS);
+ });
+});
+
+describe("entrances", () => {
+ test("a page entrance continues across redraws and is plain once it has finished", () => {
+ expect(entranceDelay(0, 0, PAGE_IN_MS)).toBe(0);
+ // Redrawn 80 ms in: it carries on from 80 ms instead of starting over.
+ expect(entranceDelay(0, 80, PAGE_IN_MS)).toBe(-80);
+ expect(entranceDelay(0, PAGE_IN_MS, PAGE_IN_MS)).toBeNull();
+ });
+
+ test("journal rows stagger, and the last starts at most 300 ms after the first however long the journal is", () => {
+ expect(rowDelay(0, 0)).toBe(0);
+ expect(rowDelay(1, 0)).toBeGreaterThan(0);
+ const starts = Array.from({ length: 60 }, (_, i) => rowDelay(i, 0) ?? 0);
+ expect(Math.max(...starts)).toBe(ROW_STAGGER_CAP_MS);
+ expect(starts).toEqual([...starts].sort((a, b) => a - b));
+ // Once every row has arrived, a redraw draws them plainly.
+ expect(rowDelay(59, ROW_STAGGER_CAP_MS + ROW_IN_MS)).toBeNull();
+ });
+});
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index d1c45a25..5586f0d8 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -190,7 +190,14 @@ Throwaway Playwright scripts (not in the repo) live in `/tmp/tm-pw/` (m7v3.mjs c
- **Home** rebuilt after Preston's reference (tenacitylabs.co): one dark page, a warm curtain glow, centered serif promise, three real verdict chips, the form, three cards (Save, Check, Bring back), then a dense footer with status, what works with it, what does not come back, five FAQs, navigation and the giant wordmark. 1815 px tall at 1440. Other pages stay, linked from the footer.
- **Waitlist** (agent WaitlistBackend, commits 3752993, a77056c, 58aab82): pending signups, no site email, confirmed-only ranks; `/api/health` email is always false. An earlier Resend double opt-in draft was built then removed on Preston's "no emails" call. SQL block ran twice on Postgres 17; pending rows get no place, a hand-confirmed row does, refs credit only active rows, anon and authenticated are refused.
- **Mac app** (agent AppFirstRun, 1438b21): `pnpm --filter @timetm/desktop package:local` builds `apps/desktop/release/Time Machine.app` signed with the stable self-signed identity "Time Machine Self Signed" (backups in `~/.timemachine-signing/`, 0600). Same designated requirement across two builds; Gatekeeper rejects it, as expected without Apple Developer ID.
-- **Blocked:** npm publish (account needs two-factor; logged in as prestonsusanto, E403); Supabase (Composio link tm-waitlist still INITIALIZING).
+- **Blocked:** npm publish (account needs two-factor; logged in as prestonsusanto, E403, nothing published); Supabase (Composio links tm-waitlist and tm-waitlist-2 both EXPIRED before sign-in finished; nothing created).
+- **Live:** PR #6 merged 87acf0f, prod deploy `mikjromdp` at https://time-machine-site.vercel.app (200, health `waitlist:false, email:false`), 1883 px at 1440, BOSS approved.
+
+### Where w2 stopped (18 Oct, usage pause)
+- Nothing in progress, tree clean. Resume when BOSS says usage refilled.
+- **Supabase next steps** once a new Composio supabase link shows ACTIVE: `SUPABASE_LIST_ALL_ORGANIZATIONS` -> `SUPABASE_CREATE_A_PROJECT` (name `time-machine-waitlist`, region `us-west-1`, free; generate the DB password locally, never print it) -> wait healthy -> `SUPABASE_BETA_RUN_SQL_QUERY` with the combined block in `docs/launch/SUPABASE_SETUP.md` §2 -> `SUPABASE_GET_PROJECT_API_KEYS` reveal, pipe the service role key on stdin to `npx vercel env add SUPABASE_SERVICE_ROLE_KEY production` (plus `SUPABASE_URL`) -> redeploy -> one real signup with a `+test` address, confirm the pending row by read-only SQL, delete it. Pending rows get no place, so expect `{ok:true,pending:true}` only.
+- **npm next steps** once Preston enables 2FA: `npm publish --access public` in `packages/cli` (send any OTP or browser URL to BOSS), then `npm view timetm version` and `npx timetm@latest demo` in a clean HOME; then update "not on npm yet" everywhere and redeploy.
+- **Email:** do not send or wire any email. Preston's own email agents handle it. Unused Vercel vars `WAITLIST_FROM` and `SITE_URL` are set on time-machine-site production; nothing reads them.
---
diff --git a/docs/PLAN.md b/docs/PLAN.md
index cb7de470..5c9be9c5 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -1,6 +1,6 @@
# Plan to MVP
-Status (17 October 2026, night): M0 to M3 code is built and merged to `main` (PRs #1 to #3). License: MIT (ADR 0010). `timetm` is one command from npm (`docs/launch/NPM_PUBLISH.md`); publication waits on the founder's account. 261 automated tests (259 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`.
+Status (18 October 2026): M0 to M3 code is built and merged to `main`. License: MIT (ADR 0010). `timetm` is one command from npm (`docs/launch/NPM_PUBLISH.md`); publishing waits on two-factor on the founder's npm account. 267 automated tests (265 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, with a launch splash, fade transitions and stable self-signed packaging; Apple signing (D08) remains. The website is public at https://time-machine-site.vercel.app: one page with a short opening screen, the promise, real app screenshots, a step-by-step section for people who don't code, the developer view, plain-word definitions and a dense footer. The waitlist saves pending signups once Supabase is set up (`docs/launch/SUPABASE_SETUP.md`); the site sends no email. The action connector remains proposed v2 (ADR 0007). Full session memory: `docs/HANDOFF.md`.
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.
diff --git a/site/assets/home.css b/site/assets/home.css
index 3d2b0d32..c499db3b 100644
--- a/site/assets/home.css
+++ b/site/assets/home.css
@@ -46,7 +46,7 @@ body.home {
.cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; max-width: 1080px; margin: 76px auto 0; padding: 0; list-style: none; }
.card { display: flex; flex-direction: column; gap: 10px; min-height: 300px; padding: 26px; border-radius: 28px; background: #000; box-shadow: inset 0 0 0 1px var(--line); }
.card-k { margin: 0; font: 500 12px/1 var(--font-mono); color: var(--ink-3); }
-.card h2 { margin: 6px 0 0; font: 400 26px/1.1 var(--font-display); }
+.card h3 { margin: 6px 0 0; font: 400 26px/1.1 var(--font-display); }
.card-t { margin: 0; color: var(--ink-2); }
.card-d { margin: 6px 0 0; color: var(--ink-3); font-size: 15px; line-height: 1.55; }
.card-term { margin: 8px 0 0; padding: 12px 14px; border-radius: 12px; background: #0d0d10; box-shadow: inset 0 0 0 1px var(--line); color: #ecebe5; font: 400 12.5px/1.65 var(--font-mono); white-space: pre-wrap; }
@@ -86,16 +86,95 @@ nav.ft-col a:hover { color: var(--ink); }
.ft-word em { color: rgba(255, 178, 122, .78); }
.ft-legal { max-width: 1200px; margin: 18px auto 0; color: var(--ink-3); font-size: 13px; }
+/* Opening screen: black stage, wordmark, loading bar at the bottom. Only when intro.js says play. */
+.intro { display: none; }
+:root[data-intro] .intro { position: fixed; z-index: 100; inset: 0; display: grid; place-content: center; justify-items: center; gap: 14px; background: #000; color: var(--ink); transition: opacity .5s var(--ease-out), visibility .5s; }
+:root[data-intro="leave"] .intro { opacity: 0; visibility: hidden; }
+:root[data-intro="play"] body { overflow: hidden; }
+.intro-word { margin: 0; font: 300 clamp(56px, 13vw, 168px)/.9 var(--font-display); font-variation-settings: "opsz" 72; letter-spacing: -.04em; white-space: nowrap; }
+.intro-word span, .intro-word em { display: inline-block; animation: intro-rise .9s var(--ease-out) both; }
+.intro-word em { color: #ff791f; animation-delay: .12s; }
+.intro-line { margin: 0; font: 500 12px/1.4 var(--font-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); animation: intro-fade .8s .35s both; }
+.intro-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255, 255, 255, .08); overflow: hidden; }
+.intro-bar i { display: block; height: 100%; width: 100%; background: linear-gradient(90deg, #d14e0b, #ff963f); transform-origin: 0 50%; transform: scaleX(.08); animation: intro-load 2.4s cubic-bezier(.1, .7, .3, 1) forwards; }
+:root[data-loaded] .intro-bar i { animation: none; transform: scaleX(1); transition: transform .35s var(--ease-out); }
+@keyframes intro-rise { from { opacity: 0; transform: translateY(.22em); } to { opacity: 1; transform: none; } }
+@keyframes intro-fade { from { opacity: 0; } to { opacity: 1; } }
+@keyframes intro-load { to { transform: scaleX(.86); } }
+
+/* Fade-ins. Without JavaScript or with reduced motion everything is simply visible. */
+:root.js [data-reveal] { opacity: 0; transform: translateY(22px); transition: opacity .7s var(--ease-out), transform .7s var(--ease-out); }
+:root.js [data-reveal].in { opacity: 1; transform: none; }
+.story-row[data-reveal] .shot { transition: transform .9s var(--ease-out); transform: scale(.97); }
+.story-row[data-reveal].in .shot { transform: none; }
+@media (prefers-reduced-motion: reduce) {
+ :root.js [data-reveal], .story-row[data-reveal] .shot { opacity: 1; transform: none; transition: none; }
+}
+
+/* App window under the hero. */
+.window { max-width: 1080px; margin: 64px auto 0; border-radius: 16px; overflow: hidden; background: #111; box-shadow: 0 0 0 1px var(--line-2), 0 40px 120px -40px rgba(255, 121, 31, .45); }
+.window-bar { display: flex; align-items: center; gap: 7px; height: 34px; padding: 0 14px; background: #19191c; border-bottom: 1px solid var(--line); }
+.window-bar i { width: 11px; height: 11px; border-radius: 50%; background: #3a3a3f; }
+.window-bar span { margin-left: 10px; color: var(--ink-3); font-size: 12.5px; }
+.window img, .shot img { display: block; width: 100%; height: auto; }
+.window figcaption { padding: 10px 16px; color: var(--ink-3); font: 400 12px/1.5 var(--font-mono); background: #0d0d10; }
+
+/* Content blocks after the stage. */
+.block { padding: clamp(72px, 9vw, 128px) max(var(--gutter), env(safe-area-inset-left, 0px)) 0; }
+.block-head { max-width: 760px; margin: 0 auto; text-align: center; }
+.eyebrow { margin: 0 0 14px; font: 500 12px/1.4 var(--font-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--accent); }
+.block h2 { margin: 0; font: 360 clamp(32px, 1.2rem + 2.8vw, 54px)/1.06 var(--font-display); font-variation-settings: "opsz" 72; letter-spacing: -.02em; text-wrap: balance; }
+.block h2 em { color: var(--accent); }
+.block .lead { max-width: 58ch; margin: 18px auto 0; color: var(--ink-2); font-size: clamp(16px, .95rem + .25vw, 18.5px); line-height: 1.6; }
+.block .cards { margin-top: 48px; }
+
+.story { display: grid; gap: clamp(56px, 8vw, 104px); max-width: 1120px; margin: 64px auto 0; padding: 0; list-style: none; }
+.story-row { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: clamp(28px, 5vw, 72px); align-items: center; }
+.story-row.flip .story-text { order: 2; }
+.story-n { display: inline-grid; place-items: center; width: 34px; height: 34px; margin: 0 0 16px; border-radius: 50%; background: #ff791f; color: #211307; font-weight: 600; }
+.story-text h3 { margin: 0; font: 400 clamp(26px, 1rem + 1.4vw, 36px)/1.12 var(--font-display); }
+.story-text p:last-child { margin: 14px 0 0; color: var(--ink-2); font-size: 17px; line-height: 1.6; }
+.shot { margin: 0; border-radius: 14px; overflow: hidden; box-shadow: 0 0 0 1px var(--line-2), 0 30px 80px -30px rgba(0, 0, 0, .9); }
+.shot figcaption { padding: 8px 14px; color: var(--ink-3); font: 400 11.5px/1.5 var(--font-mono); background: #0d0d10; }
+
+.term-block { max-width: 760px; margin: 28px auto 0; }
+.term-block pre { margin: 0; padding: 20px 22px; border-radius: 16px; background: #000; box-shadow: inset 0 0 0 1px var(--line); color: #ecebe5; font: 400 13px/1.7 var(--font-mono); overflow-x: auto; }
+.term-block .d { color: #8f8b82; }
+.term-block .f { color: var(--broken-fg); }
+.term-block .p { color: var(--works-fg); }
+.term-block figcaption { margin-top: 10px; color: var(--ink-3); font: 400 12px/1.5 var(--font-mono); text-align: center; }
+
+.why { max-width: 820px; margin: 0 auto; text-align: center; }
+.why blockquote { margin: 34px auto 0; padding: 26px 0 0; border-top: 2px solid #ff791f; max-width: 640px; }
+.why blockquote p { margin: 0; font: 400 italic clamp(24px, 1rem + 1.4vw, 34px)/1.2 var(--font-display); }
+.why blockquote footer a { display: inline-flex; align-items: center; min-height: 44px; color: var(--accent); font: 500 13px/1.4 var(--font-mono); }
+
+.words { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 clamp(24px, 4vw, 64px); max-width: 960px; margin: 40px auto 0; }
+.words div { padding: 20px 0; border-top: 1px solid var(--line-2); }
+.words dt { color: var(--ink); font: 400 24px/1.2 var(--font-display); }
+.words dd { margin: 8px 0 0; color: var(--ink-2); line-height: 1.6; }
+
+.end { padding-bottom: clamp(72px, 9vw, 120px); text-align: center; }
+.end h2 { margin-bottom: 26px; }
+
@media (max-width: 1020px) {
.ft-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}
@media (max-width: 860px) {
.cards { grid-template-columns: minmax(0, 1fr); max-width: 520px; margin-top: 56px; }
.card { min-height: 0; }
+ .story-row, .story-row.flip { grid-template-columns: minmax(0, 1fr); gap: 22px; }
+ .story-row.flip .story-text { order: 0; }
+ .words { grid-template-columns: minmax(0, 1fr); }
+ .window { margin-top: 48px; border-radius: 12px; }
}
@media (max-width: 600px) {
.stage { padding-top: 96px; }
.ft-grid { grid-template-columns: minmax(0, 1fr); gap: 34px; }
.bar-cta { padding: 0 14px; font-size: 13.5px; }
}
+@media (max-width: 600px) {
+ .term-block pre { padding: 14px; font-size: 11.5px; white-space: pre-wrap; overflow-wrap: anywhere; }
+ .window-bar span { display: none; }
+}
@media (prefers-reduced-transparency: reduce) { .stage::before { display: none; } }
diff --git a/site/assets/img/app-06-rewind-review-dark.jpg b/site/assets/img/app-06-rewind-review-dark.jpg
deleted file mode 100644
index 7444978d..00000000
Binary files a/site/assets/img/app-06-rewind-review-dark.jpg and /dev/null differ
diff --git a/site/assets/img/app-06-rewind-review-light.jpg b/site/assets/img/app-06-rewind-review-light.jpg
deleted file mode 100644
index 089d1907..00000000
Binary files a/site/assets/img/app-06-rewind-review-light.jpg and /dev/null differ
diff --git a/site/assets/img/app-broken-m.jpg b/site/assets/img/app-broken-m.jpg
new file mode 100644
index 00000000..ec76b3ce
Binary files /dev/null and b/site/assets/img/app-broken-m.jpg differ
diff --git a/site/assets/img/app-broken.jpg b/site/assets/img/app-broken.jpg
new file mode 100644
index 00000000..e46d0c6e
Binary files /dev/null and b/site/assets/img/app-broken.jpg differ
diff --git a/site/assets/img/app-dial-m.jpg b/site/assets/img/app-dial-m.jpg
new file mode 100644
index 00000000..e7b26bb7
Binary files /dev/null and b/site/assets/img/app-dial-m.jpg differ
diff --git a/site/assets/img/app-done-m.jpg b/site/assets/img/app-done-m.jpg
new file mode 100644
index 00000000..b8c5c22f
Binary files /dev/null and b/site/assets/img/app-done-m.jpg differ
diff --git a/site/assets/img/app-done.jpg b/site/assets/img/app-done.jpg
new file mode 100644
index 00000000..3d5b3c15
Binary files /dev/null and b/site/assets/img/app-done.jpg differ
diff --git a/site/assets/img/app-review-800.jpg b/site/assets/img/app-review-800.jpg
new file mode 100644
index 00000000..8af7bc86
Binary files /dev/null and b/site/assets/img/app-review-800.jpg differ
diff --git a/site/assets/img/app-review-wide.jpg b/site/assets/img/app-review-wide.jpg
new file mode 100644
index 00000000..03420f2c
Binary files /dev/null and b/site/assets/img/app-review-wide.jpg differ
diff --git a/site/assets/img/app-review.jpg b/site/assets/img/app-review.jpg
new file mode 100644
index 00000000..b86b6f4e
Binary files /dev/null and b/site/assets/img/app-review.jpg differ
diff --git a/site/assets/intro.js b/site/assets/intro.js
new file mode 100644
index 00000000..2424cbc7
--- /dev/null
+++ b/site/assets/intro.js
@@ -0,0 +1,50 @@
+/* Opening screen and fade-ins.
+ Opening: black stage, the wordmark, a loading bar at the bottom. First page load of a visit only.
+ The bar completes when the page and its fonts are ready, and the screen leaves then
+ (at least 900 ms, so it never flashes). Any click or key skips it. Reduced motion or no
+ JavaScript: no opening at all. Loaded in without defer so it can decide before paint. */
+(function () {
+ "use strict";
+ var root = document.documentElement;
+ var reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
+ var seen = false;
+ try { seen = sessionStorage.getItem("tm-intro") === "1"; sessionStorage.setItem("tm-intro", "1"); } catch (e) { /* Storage is optional. */ }
+ if (!reduced && !seen) root.setAttribute("data-intro", "play");
+
+ var started = Date.now();
+ function finish() {
+ if (root.getAttribute("data-intro") !== "play") return;
+ root.setAttribute("data-intro", "leave");
+ setTimeout(function () { root.removeAttribute("data-intro"); }, 520);
+ }
+ function whenLoaded() {
+ root.setAttribute("data-loaded", "");
+ setTimeout(finish, Math.max(0, 900 - (Date.now() - started)));
+ }
+ if (root.getAttribute("data-intro") === "play") {
+ // Ready means the page is parsed and its fonts are in, so the first screen renders right.
+ // Screenshots further down the page keep loading behind it.
+ document.addEventListener("DOMContentLoaded", function () {
+ (document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(whenLoaded, whenLoaded);
+ });
+ addEventListener("keydown", finish, { once: true });
+ addEventListener("pointerdown", finish, { once: true });
+ // Never hold the page hostage to a slow connection.
+ setTimeout(finish, 2500);
+ }
+
+ // Fade-ins: elements marked data-reveal rise in once when they enter the screen.
+ document.addEventListener("DOMContentLoaded", function () {
+ var items = document.querySelectorAll("[data-reveal]");
+ if (reduced || !("IntersectionObserver" in window)) {
+ for (var i = 0; i < items.length; i++) items[i].classList.add("in");
+ return;
+ }
+ var io = new IntersectionObserver(function (entries) {
+ for (var j = 0; j < entries.length; j++) {
+ if (entries[j].isIntersecting) { entries[j].target.classList.add("in"); io.unobserve(entries[j].target); }
+ }
+ }, { rootMargin: "0px 0px -8% 0px", threshold: 0.12 });
+ for (var k = 0; k < items.length; k++) io.observe(items[k]);
+ });
+})();
diff --git a/site/company.html b/site/company.html
index 03746062..7845d94c 100644
--- a/site/company.html
+++ b/site/company.html
@@ -53,7 +53,7 @@ Build witha wa
time machine status · 15 oct 2026
-
261 automated tests. 259 pass on macOS. 2 run only on Linux.
+
267 automated tests. 265 pass on macOS. 2 run only on Linux.
17 bugs found by two reviews, all fixed test first
7 MCP tools on the same engine as the CLI
Local save points. Known secrets filtered. Review what you capture.
@@ -126,7 +126,7 @@
Replit's agent wiped a production database.
MCP server Seven tools for any agent that speaks MCP. Built
Store outside the project In ~/.timemachine, with a Git bundle, so save points survive the project folder being deleted. Built
Checks in a separate copy With the network off when a sandbox exists, and BLOCKED when that cannot be enforced. Built
-
Tests 261 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
+
Tests 267 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 package timetm, command tm. Build from source until then.Not published yet
Mac app History, save point detail, Rewind and Compare, on the same engine. Not signed or shipped yet. In development
Action connector for Claude and ChatGPT Version 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 1 · Engine and CLI
Done when: every acceptance test passes on macOS and Linux CI, the demo runs end to end, and your folder and current branch are byte for byte unchanged after every command.
-
Status: met. 261 automated tests pass in CI on macOS and Linux. Publishing timetm to npm is the last step.
+
Status: met. 267 automated tests pass in CI on macOS and Linux. Publishing timetm to npm is the last step.
Gate 2 · Mac app
diff --git a/site/index.html b/site/index.html
index 40267aaa..ae72b764 100644
--- a/site/index.html
+++ b/site/index.html
@@ -24,9 +24,15 @@
+
Skip to content
+
+
Time Machine
+
Rewinding to the last version that worked
+
+
Time Machine
@@ -56,17 +62,64 @@ Get back the version that passed your tests.
-
diff --git a/site/story.html b/site/story.html
index 4ab1570d..49161245 100644
--- a/site/story.html
+++ b/site/story.html
@@ -282,8 +282,8 @@
What works today.What doesn't
Planned
The Ladder Pricing. Nothing is for sale yet.
-
261
-
automated tests. 259 pass on macOS. 2 run only on Linux. Both pass in CI.
+
267
+
automated tests. 265 pass on macOS. 2 run only on Linux. Both pass in CI.