Skip to content

Commit a2485e4

Browse files
Hardening v2.7claude
andcommitted
chore(dev): add the launcher transition and app-preview harnesses
Two standalone dev-only pages for the launcher <-> meeting-notes transition, following the existing thinkingDotHarness / streamingCodeHarness convention (a root .html entry beside a src/dev/*.tsx module). They exist because the transition could not be verified any other way. A Chrome extension tab reports visibilityState 'hidden', so requestAnimationFrame never fires there and Framer Motion never advances a frame — a driven animation screenshots identically at 0 ms and 400 ms, which reads as "no animation" when the animation is fine. These pages render the two layers standalone so a real browser can drive them. Not referenced by any build entry, shipped bundle or npm script; opened directly during development. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BibR1zU9YXQgzT8Ty18yTY
1 parent 045ffde commit a2485e4

4 files changed

Lines changed: 364 additions & 0 deletions

File tree

launcherAppPreview.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8" />
6+
<title>Launcher app preview (dev-only, not shipped)</title>
7+
</head>
8+
9+
<body style="margin:0;">
10+
<div id="preview-root"></div>
11+
<script type="module" src="/src/dev/launcherAppPreview.tsx"></script>
12+
</body>
13+
14+
</html>

launcherTransitionHarness.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8" />
6+
<title>Launcher page-transition harness (dev-only, not shipped)</title>
7+
</head>
8+
9+
<body style="margin:0;">
10+
<div id="harness-root"></div>
11+
<script type="module" src="/src/dev/launcherTransitionHarness.tsx"></script>
12+
</body>
13+
14+
</html>

src/dev/launcherAppPreview.tsx

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// DEV-ONLY preview that renders the REAL <Launcher> (and therefore the real
2+
// <MeetingDetails>) against a stubbed window.electronAPI, so the list ⇄
3+
// meeting-notes transition on screen is the app's own code path rather than a
4+
// mirror of it. Not shipped — vite's build input is index.html alone.
5+
//
6+
// The stub only has to satisfy the calls Launcher/MeetingDetails make on mount;
7+
// everything is optional-chained in the components, so anything missing is a
8+
// no-op rather than a crash.
9+
import React from 'react';
10+
import { createRoot } from 'react-dom/client';
11+
import '../index.css';
12+
13+
const now = Date.now();
14+
const iso = (minsAgo: number) => new Date(now - minsAgo * 60_000).toISOString();
15+
16+
const MEETINGS = [
17+
{ id: 'm1', title: 'Weekly sync — engineering', mins: 45 },
18+
{ id: 'm2', title: 'Design review: launcher transitions', mins: 180 },
19+
{ id: 'm3', title: 'Customer call — Northwind', mins: 320 },
20+
{ id: 'm4', title: '1:1 with Priya', mins: 1500 },
21+
{ id: 'm5', title: 'Roadmap planning Q4', mins: 1600 },
22+
{ id: 'm6', title: 'Incident retro — audio pipeline', mins: 2900 },
23+
].map((m, i) => ({
24+
id: m.id,
25+
title: m.title,
26+
date: iso(m.mins),
27+
duration: `${18 + i * 7}:${String((i * 13) % 60).padStart(2, '0')}`,
28+
summary: 'Short summary line shown in the list row.',
29+
detailedSummary: {
30+
schemaVersion: 3,
31+
tldr: [
32+
'The launcher ⇄ meeting-notes navigation had no real transition and read as a blink.',
33+
'PR #511 replaces it with a cover/uncover model: the notes panel is always the upper layer.',
34+
],
35+
keyPoints: [
36+
'Details slides in 24px from the right and fades over 200ms.',
37+
'The list never moves laterally — it recedes by scaling up to 1.03.',
38+
'Scaling up rather than down keeps the clip box from uncovering a window edge.',
39+
],
40+
actionItems: [
41+
'Merge PR #511 and rebuild the renderer.',
42+
'Check the reduced-motion branch with the OS setting enabled.',
43+
],
44+
overview:
45+
'Moving between the launcher list and a meeting’s notes previously used a single ' +
46+
'AnimatePresence mode="wait" doing a 0.15s opacity-only fade. Because mode="wait" ' +
47+
'serialises the two fades, the old panel dissolved to nothing, a dead gap followed, and ' +
48+
'only then did the new one fade up — no direction and no continuity. The replacement ' +
49+
'keeps both layers mounted so one genuinely covers the other.',
50+
},
51+
transcript: Array.from({ length: 8 }, (_, k) => ({
52+
speaker: k % 2 === 0 ? 'You' : 'Priya',
53+
text: 'Transcript line ' + (k + 1) + ' — rendered by the real MeetingDetails component.',
54+
timestamp: k * 27,
55+
})),
56+
usage: [],
57+
}));
58+
59+
const noop = async () => undefined;
60+
61+
// A plain object, deliberately not a Proxy: the components read non-function
62+
// properties too (platformUtils does `electronAPI?.platform.startsWith(...)` at
63+
// module scope), so a catch-all that hands back a function breaks the app
64+
// before it paints. Everything the components call is optional-chained, so any
65+
// method missing here is simply a no-op.
66+
const stub = {
67+
platform: 'darwin',
68+
getRecentMeetings: async () => MEETINGS,
69+
getMeetingDetails: async (id: string) => MEETINGS.find(m => m.id === id) ?? null,
70+
getUpcomingEvents: async () => [],
71+
onboardingGetFlags: async () => ({}),
72+
onboardingSetFlag: noop,
73+
getSetting: async () => null,
74+
setSetting: noop,
75+
calendarRefresh: noop,
76+
// Pinned so the preview sits in the app's normal resting state: without
77+
// these the catch-all makes them truthy and you get the "Meeting ongoing"
78+
// pill and the undetectable dashed border, which are stub artefacts.
79+
getMeetingActive: async () => false,
80+
getUndetectable: async () => true,
81+
seedDemo: noop,
82+
searchGlobalMeetings: async () => ({ enabled: false, results: [] }),
83+
};
84+
85+
// Effects DO call methods that are not optional-chained (ConnectCalendarButton
86+
// does `window.electronAPI.getCalendarStatus()`, useShortcuts does
87+
// `.onKeybindsUpdate()`), and their return values get awaited, called as
88+
// unsubscribe handles, and iterated. So the fallback has to be all three at
89+
// once: callable, thenable, and object-like when resolved.
90+
//
91+
// `resolved` deliberately has NO `then`, or awaiting it would recurse forever.
92+
const resolved: any = new Proxy({}, {
93+
get: (_t, k) =>
94+
k === 'then' ? undefined
95+
: k === 'forEach' ? () => {}
96+
: k === 'map' || k === 'filter' ? () => []
97+
: k === Symbol.iterator ? function* () {}
98+
: undefined,
99+
});
100+
const anyFn: any = new Proxy(function () {}, {
101+
apply: () => anyFn, // unsub() / destroy()
102+
// `.then(cb)` must return the chainable, not cb's result, or the very
103+
// common `getX().then(...).catch(...)` blows up on the .catch.
104+
get: (_t, k) =>
105+
k === 'then'
106+
? (res: any) => { try { res?.(resolved); } catch { /* ignore */ } return anyFn; }
107+
: anyFn,
108+
});
109+
110+
// The proxy sits *over* the object above rather than replacing it, so real data
111+
// properties like `platform` still read as data.
112+
const api = new Proxy(stub as Record<string, unknown>, {
113+
get: (t, k: string) => (k in t ? t[k] : anyFn),
114+
has: () => true,
115+
});
116+
117+
(window as unknown as { electronAPI: unknown }).electronAPI = api;
118+
119+
// Rendered only after the stub is installed — Launcher's module graph touches
120+
// window.electronAPI during mount effects.
121+
const { default: Launcher } = await import('../components/Launcher');
122+
123+
function Preview() {
124+
return (
125+
<div className="h-screen w-screen overflow-hidden">
126+
<Launcher
127+
onStartMeeting={() => {}}
128+
onOpenSettings={() => {}}
129+
onOpenProfile={() => {}}
130+
onOpenModes={() => {}}
131+
onPageChange={() => {}}
132+
/>
133+
</div>
134+
);
135+
}
136+
137+
createRoot(document.getElementById('preview-root')!).render(<Preview />);
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// DEV-ONLY visual rig for the Launcher ⇄ MeetingDetails page transition.
2+
// Not part of the shipped app — vite's build input is index.html only
3+
// (vite.config.mts), so this and launcherTransitionHarness.html exist for the
4+
// dev server alone. Same precedent as thinkingDotHarness.tsx.
5+
//
6+
// It renders the REAL variants (imported from the same module Launcher.tsx
7+
// imports, ./components/launcherPageTransition) against the real
8+
// layer structure (absolute inset-0, z-2 over z-1, overflow-hidden wrapper) so
9+
// what plays here is what plays in the app. The two pages are stand-ins with
10+
// list- and document-weight content — enough text at the real sizes to judge
11+
// whether the parallax reads and whether anything ghosts mid-travel.
12+
//
13+
// The speed control is the point of the rig: the skill is explicit that timing
14+
// faults are invisible at full speed. Watch it at 5x before believing it.
15+
import React, { useState } from 'react';
16+
import { createRoot } from 'react-dom/client';
17+
import { motion, AnimatePresence } from 'framer-motion';
18+
import '../index.css';
19+
import type { TargetAndTransition } from 'framer-motion';
20+
21+
// Mirrors Launcher.tsx:424-462 (PR #511). Copied rather than imported because
22+
// the app builds these inline inside the component, closed over the
23+
// useReducedMotion() result. Keep in sync by hand — if the numbers below stop
24+
// matching Launcher.tsx, this rig is lying to you.
25+
const NAV_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
26+
27+
const detailsInitial = (rm: boolean): TargetAndTransition =>
28+
rm ? { opacity: 0 } : { opacity: 0, transform: 'translateX(24px)' };
29+
const detailsEnter = (rm: boolean): TargetAndTransition =>
30+
rm
31+
? { opacity: 1, transition: { duration: 0.12, ease: 'linear' } }
32+
: {
33+
opacity: 1,
34+
transform: 'translateX(0px)',
35+
transition: {
36+
transform: { duration: 0.34, ease: NAV_EASE },
37+
opacity: { duration: 0.2, ease: 'easeOut' },
38+
},
39+
};
40+
const detailsExit = (rm: boolean): TargetAndTransition =>
41+
rm
42+
? { opacity: 0, pointerEvents: 'none', transition: { duration: 0.12, ease: 'linear' } }
43+
: {
44+
opacity: 0,
45+
transform: 'translateX(20px)',
46+
pointerEvents: 'none',
47+
transition: {
48+
transform: { duration: 0.26, ease: NAV_EASE },
49+
opacity: { duration: 0.22, ease: 'easeOut' },
50+
},
51+
};
52+
const listRecede = (rm: boolean): TargetAndTransition =>
53+
rm
54+
? { opacity: 0.999, transition: { duration: 0.12, ease: 'linear' } }
55+
: { transform: 'scale(1.03)', transition: { duration: 0.3, ease: NAV_EASE } };
56+
const listSettle = (rm: boolean): TargetAndTransition =>
57+
rm
58+
? { opacity: 1, transition: { duration: 0.12, ease: 'linear' } }
59+
: { transform: 'scale(1)', transition: { duration: 0.34, ease: NAV_EASE } };
60+
61+
const SPEEDS = [1, 2, 3, 5, 10];
62+
63+
function FakeList({ onOpen }: { onOpen: (n: number) => void }) {
64+
return (
65+
<div className="h-full w-full flex flex-col bg-bg-primary text-text-primary overflow-hidden">
66+
<section className="bg-bg-elevated px-8 pt-6 pb-8 border-b border-border-subtle shrink-0">
67+
<h1 className="text-3xl font-medium tracking-wide">My Natively</h1>
68+
</section>
69+
<div className="flex-1 overflow-y-auto px-8 py-4 space-y-1">
70+
{Array.from({ length: 14 }, (_, i) => (
71+
<button
72+
key={i}
73+
onClick={() => onOpen(i)}
74+
className="w-full text-left px-4 py-3 rounded-xl hover:bg-white/5 flex items-center justify-between"
75+
>
76+
<span className="text-[14px]">Weekly sync — engineering #{i + 1}</span>
77+
<span className="text-[13px] text-text-secondary">3:1{i}pm</span>
78+
</button>
79+
))}
80+
</div>
81+
</div>
82+
);
83+
}
84+
85+
function FakeDetails({ n }: { n: number }) {
86+
return (
87+
<div className="h-full w-full flex flex-col bg-bg-elevated text-text-secondary overflow-hidden">
88+
<div className="flex-1 overflow-y-auto">
89+
{/* Plain div, matching MeetingDetails after its own delayed
90+
mount fade-up was removed: the page transition owns the
91+
entrance, so the arriving panel carries its content with it
92+
instead of landing empty and filling in afterwards. */}
93+
<div className="max-w-4xl mx-auto px-8 py-8">
94+
<h1 className="text-2xl font-medium text-text-primary mb-2">
95+
Weekly sync — engineering #{n + 1}
96+
</h1>
97+
<p className="text-[13px] text-text-secondary mb-8">Aug 26 · 42:18</p>
98+
{Array.from({ length: 8 }, (_, i) => (
99+
<p key={i} className="text-[14px] leading-relaxed mb-4">
100+
Paragraph {i + 1}. Body copy at the real 14px so the mid-travel frames
101+
show whether the leaving page stays legible over the arriving one, which
102+
is the failure a dissolve has and a push should not.
103+
</p>
104+
))}
105+
</div>
106+
</div>
107+
</div>
108+
);
109+
}
110+
111+
function Harness() {
112+
const [selected, setSelected] = useState<number | null>(null);
113+
const [speed, setSpeed] = useState(1);
114+
const [reduced, setReduced] = useState(false);
115+
116+
117+
return (
118+
<div className="h-screen w-screen flex flex-col bg-bg-primary">
119+
<div className="shrink-0 flex items-center gap-4 px-4 h-[52px] border-b border-border-subtle text-text-primary text-[13px]">
120+
<button
121+
onClick={() => setSelected(selected === null ? 0 : null)}
122+
className="px-3 py-1.5 rounded-full bg-white/10 hover:bg-white/20"
123+
>
124+
{selected === null ? 'Push →' : '← Pop'}
125+
</button>
126+
<div className="flex items-center gap-1">
127+
{SPEEDS.map(s => (
128+
<button
129+
key={s}
130+
onClick={() => setSpeed(s)}
131+
className={`px-2.5 py-1 rounded-full ${speed === s ? 'bg-white/25' : 'bg-white/5 hover:bg-white/10'}`}
132+
>
133+
{s}x slower
134+
</button>
135+
))}
136+
</div>
137+
<label className="flex items-center gap-2 ml-auto">
138+
<input type="checkbox" checked={reduced} onChange={e => setReduced(e.target.checked)} />
139+
prefers-reduced-motion
140+
</label>
141+
</div>
142+
143+
{/* Mirrors Launcher.tsx: the content area below the persistent
144+
header chrome, clipping both absolutely-positioned layers. */}
145+
<div className="relative flex-1 flex flex-col overflow-hidden">
146+
{/* Slow motion scales the variants' own durations (see
147+
`slowed` below) rather than using MotionConfig, so the
148+
per-direction PUSH/POP split survives. */}
149+
<AnimatePresence initial={false}>
150+
{selected !== null ? (
151+
<motion.div
152+
key="details"
153+
data-page="details"
154+
className="absolute inset-0 z-20 overflow-hidden"
155+
initial={detailsInitial(reduced)}
156+
animate={slow(detailsEnter(reduced), speed)}
157+
exit={slow(detailsExit(reduced), speed)}
158+
>
159+
<FakeDetails n={selected} />
160+
</motion.div>
161+
) : (
162+
<motion.div
163+
key="launcher"
164+
data-page="launcher"
165+
className="absolute inset-0 z-10 flex flex-col overflow-hidden"
166+
initial={listRecede(reduced)}
167+
animate={slow(listSettle(reduced), speed)}
168+
exit={slow(listRecede(reduced), speed)}
169+
>
170+
<FakeList onOpen={setSelected} />
171+
</motion.div>
172+
)}
173+
</AnimatePresence>
174+
</div>
175+
</div>
176+
);
177+
}
178+
179+
// Scale durations without touching the curve, so slow motion stretches the real
180+
// easing rather than showing a different animation. Handles both a flat
181+
// `transition.duration` and the per-property form PR #511 uses.
182+
function slow(target: TargetAndTransition, factor: number): TargetAndTransition {
183+
if (factor === 1) return target;
184+
const t = target.transition as Record<string, any> | undefined;
185+
if (!t) return target;
186+
const out: Record<string, any> = {};
187+
for (const [k, v] of Object.entries(t)) {
188+
out[k] = typeof v === 'object' && v && 'duration' in v
189+
? { ...v, duration: v.duration * factor }
190+
: k === 'duration' ? v * factor : v;
191+
}
192+
return { ...target, transition: out };
193+
}
194+
195+
createRoot(document.getElementById('harness-root')!).render(
196+
<React.StrictMode>
197+
<Harness />
198+
</React.StrictMode>,
199+
);

0 commit comments

Comments
 (0)