Skip to content

Commit c0d3cc0

Browse files
Hardening v2.7claude
andcommitted
polish(launcher): put the length into the settle, on an Apple spring
Slower, but deliberately not slower everywhere. Two halves of this transition want opposite things. The veil is mechanical. It is also the only thing standing between the notes and the list, so it stays at 160ms: every frame after it reaches full opacity the list is completely hidden, so time added beyond that point buys something nobody can see — and time added before it puts the list back underneath the notes text, which is the ghosting this whole approach exists to remove. It keeps its bezier. The content is the part worth watching, so that is where the length goes, and it moves to a spring. Apple states springs as duration + bounce rather than stiffness/damping, and the reason the result reads as an object settling rather than a value being tweened is the asymmetric deceleration: a spring keeps a trace of momentum right at the end where a bezier simply stops. Bounce is 0.05 — a full-window surface that overshoots reads as sloppy rather than playful, so what is taken from the spring is the settle, not the wobble. Measured: monotonic, no overshoot. Content now also comes forward from scale(0.985) while travelling its last 28px, which is what separates this from a slide along a rail. At full size that is a couple of pixels of text — the amount you feel without seeing. The list recedes slightly further (4.5%) on the same spring, so on the way back the two layers settle as one movement rather than two. Both directions settle at ~490ms, up from ~280ms, which is the top of the 200-500ms band a full-surface transition belongs in. Exit uses a shorter, zero-bounce spring: the system getting out of the way should still outpace the user deciding to leave. The invariant from the previous commit is re-verified frame by frame at 60fps in both directions: whenever content opacity is above 0.05, veil opacity is already above 0.97. Reduced motion is deliberately NOT slowed — it stays a 160ms crossfade with no springs and no transforms. Slower is the opposite of what that setting asks for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JAC7SCyAxu4vFYoGLa215T
1 parent 43f9492 commit c0d3cc0

1 file changed

Lines changed: 52 additions & 30 deletions

File tree

src/components/Launcher.tsx

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -434,8 +434,21 @@ const Launcher: React.FC<LauncherProps> = ({ onStartMeeting, onOpenSettings, onO
434434
// only ever fades the *text* — the background never flickers, and the
435435
// 20px the content travels only ever uncovers more of the same colour.
436436
const prefersReducedMotion = useReducedMotion();
437-
// iOS/Ionic panel curve — strong ease-out, no overshoot.
437+
// iOS/Ionic panel curve — strong ease-out, no overshoot. Used for the parts
438+
// that are mechanical rather than expressive: the veil landing and lifting.
438439
const NAV_EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
440+
441+
// Apple states its springs as duration + bounce rather than stiffness and
442+
// damping, and this is the reason the motion reads as an object settling
443+
// rather than a value being tweened: a spring decelerates asymmetrically and
444+
// keeps a trace of momentum right at the end, where a bezier simply stops.
445+
// Bounce stays near zero — a full-window surface that overshoots reads as
446+
// sloppy, not playful. What is wanted is the settle, not the wobble.
447+
const SETTLE = { type: 'spring' as const, duration: 0.62, bounce: 0.05 };
448+
// Leaving is the system getting out of the way, so it is quicker and has no
449+
// bounce at all.
450+
const RELEASE = { type: 'spring' as const, duration: 0.46, bounce: 0 };
451+
439452
// Exactly the token MeetingDetails paints its own root with. If these two
440453
// ever disagree the seam becomes visible as a tint shift mid-transition.
441454
const NOTES_SURFACE = isLight ? 'bg-bg-secondary' : 'bg-bg-elevated';
@@ -448,69 +461,78 @@ const Launcher: React.FC<LauncherProps> = ({ onStartMeeting, onOpenSettings, onO
448461
gone: { pointerEvents: 'none' },
449462
};
450463

451-
// The grey. In on its own, quickly, ahead of the content.
464+
// The grey. Deliberately NOT slowed along with everything else: it is the
465+
// mechanical half of the transition, and it is also the thing standing
466+
// between the notes and the list. It reaches full opacity around 160ms, and
467+
// every frame after that the list is completely hidden — so time spent
468+
// beyond this point would be spent on something nobody can see, while time
469+
// added *before* it would put the list back underneath the notes text.
452470
const veilVariants: Variants = prefersReducedMotion
453471
? {
454472
hidden: { opacity: 0 },
455-
shown: { opacity: 1, transition: { duration: 0.14, ease: 'linear' } },
456-
gone: { opacity: 0, transition: { duration: 0.14, ease: 'linear' } },
473+
shown: { opacity: 1, transition: { duration: 0.16, ease: 'linear' } },
474+
gone: { opacity: 0, transition: { duration: 0.16, ease: 'linear' } },
457475
}
458476
: {
459477
hidden: { opacity: 0 },
460-
shown: { opacity: 1, transition: { duration: 0.12, ease: 'easeOut' } },
461-
// Lingers a beat after the text has gone, then lifts to reveal the
462-
// list. Leaving in the other order would flash the list behind still-
463-
// legible notes.
464-
gone: { opacity: 0, transition: { duration: 0.18, ease: 'easeOut', delay: 0.1 } },
478+
shown: { opacity: 1, transition: { duration: 0.16, ease: 'easeOut' } },
479+
// Lingers well past the text, then lifts. Leaving in the other order
480+
// would flash the list behind still-legible notes.
481+
gone: { opacity: 0, transition: { duration: 0.3, ease: NAV_EASE, delay: 0.18 } },
465482
};
466483

467-
// The notes themselves. The delay is the whole point and is tuned to one
468-
// invariant, verified frame by frame: the content must never be legible
469-
// while the list still is. Concretely — whenever content opacity is above
470-
// ~0.05, veil opacity is already above ~0.97, so there is nothing left of
471-
// the list to read through the text.
484+
// The notes. This is where the length lives — the part worth watching.
485+
//
486+
// The delay is tuned to one invariant, verified frame by frame: content must
487+
// never be legible while the list still is. Whenever content opacity is above
488+
// ~0.05, veil opacity is already above ~0.97.
489+
//
490+
// The scale is what separates this from a slide. Coming forward from 0.985
491+
// while travelling the last 28px reads as the page arriving in depth rather
492+
// than sliding along a rail; at full size the difference is a couple of
493+
// pixels of text, which is exactly the amount you feel without seeing.
472494
const contentVariants: Variants = prefersReducedMotion
473495
? {
474496
hidden: { opacity: 0 },
475-
shown: { opacity: 1, transition: { duration: 0.14, ease: 'linear', delay: 0.04 } },
476-
gone: { opacity: 0, transition: { duration: 0.12, ease: 'linear' } },
497+
shown: { opacity: 1, transition: { duration: 0.16, ease: 'linear', delay: 0.06 } },
498+
gone: { opacity: 0, transition: { duration: 0.14, ease: 'linear' } },
477499
}
478500
: {
479-
hidden: { opacity: 0, transform: 'translateX(20px)' },
501+
hidden: { opacity: 0, transform: 'translateX(28px) scale(0.985)' },
480502
shown: {
481503
opacity: 1,
482-
transform: 'translateX(0px)',
504+
transform: 'translateX(0px) scale(1)',
483505
transition: {
484-
opacity: { duration: 0.18, ease: 'easeOut', delay: 0.1 },
485-
transform: { duration: 0.28, ease: NAV_EASE },
506+
opacity: { duration: 0.34, ease: 'easeOut', delay: 0.15 },
507+
transform: SETTLE,
486508
},
487509
},
488-
// Text clears fast — the system getting out of the way should outpace
489-
// the user deciding to leave.
490510
gone: {
491511
opacity: 0,
492-
transform: 'translateX(16px)',
512+
transform: 'translateX(22px) scale(0.99)',
493513
transition: {
494-
opacity: { duration: 0.11, ease: 'easeOut' },
495-
transform: { duration: 0.2, ease: NAV_EASE },
514+
opacity: { duration: 0.18, ease: 'easeOut' },
515+
transform: RELEASE,
496516
},
497517
},
498518
};
499519

500520
// The list keeps opacity 1 throughout — it is covered by an opaque surface,
501-
// so cross-dissolving it too would only muddy the blend.
521+
// so cross-dissolving it too would only muddy the blend. It recedes a little
522+
// further than before (4.5%) and on the same spring as the notes, so coming
523+
// back the two halves settle as one movement rather than two.
502524
//
503525
// Under reduced motion it still needs *something* to animate even though it
504526
// must not move: AnimatePresence drops an exiting child as soon as its exit
505527
// animation finishes, and an empty target finishes on the first frame —
506528
// measured, the list unmounted at 3ms and the veil then faded in over bare
507529
// background. The 0.999 is imperceptible and holds the layer for the fade.
508530
const listRecede: TargetAndTransition = prefersReducedMotion
509-
? { opacity: 0.999, transition: { duration: 0.14, ease: 'linear' } }
510-
: { transform: 'scale(1.03)', transition: { duration: 0.28, ease: NAV_EASE } };
531+
? { opacity: 0.999, transition: { duration: 0.16, ease: 'linear' } }
532+
: { transform: 'scale(1.045)', transition: RELEASE };
511533
const listSettle: TargetAndTransition = prefersReducedMotion
512-
? { opacity: 1, transition: { duration: 0.14, ease: 'linear' } }
513-
: { transform: 'scale(1)', transition: { duration: 0.3, ease: NAV_EASE } };
534+
? { opacity: 1, transition: { duration: 0.16, ease: 'linear' } }
535+
: { transform: 'scale(1)', transition: SETTLE };
514536

515537
return (
516538
<div className="h-full w-full flex flex-col bg-bg-primary text-text-primary font-sans overflow-hidden selection:bg-accent-secondary/30">

0 commit comments

Comments
 (0)