UI-SPEC — Logo v12: reveal timing, Journey sequence, reduced-motion still path
Issue: OBJ-2225 · Task: Orion's Task 2 · Implements: Codi's Tasks 5 + 6 (PR B logo-v12-motion) Companion spec: logo-v12-oblique-wordmark.md (Task 1 → Codi's Tasks 3 + 4) Mockup: docs/ui-specs/mockups/logo-v12-motion/reveal-timing.html (+ screenshots/, incl. motion GIFs)
Naming note (OBJ-2459, added retroactively): at the time this spec was written, the celebratory sequence below lived in
ObjectuveLogoJourney.vueand this doc called it the "Journey sequence" throughout. That component has since been renamed toObjectuveLogoAchieved.vue— component/file references below have been updated to match.ObjectuveLogoJourneynow names a different component: the 900ms launch-interstitial sweep. See Logo rationale § Motion for the current taxonomy. The prose term "Journey sequence" is left as this spec's own historical label for the phases it designed (P1–P4 below) and should be read as "the achieved-animation sequence."
Scope
Three things. The reveal in ObjectuveLogo.vue reads as two events with a pause between them; it should read as one gesture. The Journey sequence in ObjectuveLogoAchieved.vue spends its slowest frames on its least eventful moment and then redraws a ring the user has already seen. And ObjectuveLogoAchieved.vue has no reduced-motion path at all — it plays a full sweep, spring scale, sunburst and particle splash regardless of the setting.
Geometry, HSL blending, bloom, wave radii and ignition order are unchanged everywhere. This is timing, easing, one count, and one new code path.
0. Correction to the framing — read this before Task 5
LegendaryCelebration.vue does not render ObjectuveLogoAchieved. It renders BadgeImage. AchievementToast.vue doesn't render it either. Verified on master: ObjectuveLogoAchieved has exactly one consumer in the whole codebase —
ionic_frontend/src/views/welcome/slides/WelcomeIntro.vue:87
<ObjectuveLogoAchieved :size="160" /> ← inside `hidden md:flex`, desktop onlySo three things carried through the issue body and the task package are not true, and Codi should not chase them:
DISMISS_MS = 8000is not a constraint on this component. Nothing connects the two. The number is real; the relationship isn't. Do not report the Journey total "against the 8s dismiss window" — there is no window.- Nobody is listening to
complete.WelcomeIntro.vuebinds no handler. The single-emit invariant is still required (§4), but as a contract, not because a consumer would deadlock today. The real deadlock exposure isObjectuveLogo'sanimationComplete, whichSignIn.vueandSignUp.vuedo gate on. - It never renders on mobile. The column is
hidden md:flex.
The tightening is still worth shipping, for a better reason than the one on the ticket: the sequence sits beside the primary CTA on the first screen of onboarding, next to "Let's go →". 6.48s of animation that finishes long after a new user has read three lines and clicked through is decoration they have already walked away from. And the missing reduced-motion path matters more here than anywhere else in the product — this is the first screen a motion-sensitive user ever sees.
1. The reveal — behaviour contract
The ring closes. Before it finishes, the dot ignites; before the dot settles, the wordmark slides in under it. One gesture with three overlapping parts — never three parts in a queue.
Two rules follow, and they matter more than the constants:
- The dot fires while the ring is still moving. Specifically at 88% of the sweep, with ~12° still to close. If the dot ever waits for the sweep to end, the fix didn't happen.
- The wordmark starts before the dot stops. It slides in 90ms after the dot fires, under a dot that is still settling.
1.1 Absolute timeline — animate="reveal"
All offsets measured from the start of the reveal (t = 0). This is the contract; the constants in §1.2 are one way to realise it.
| Beat | Starts | Duration | Ends |
|---|---|---|---|
Ring sweep 0° → 270°, easeOutQuint | 0 | 900 | 900 |
Dot pop (dot-pop, 0.34s) | 792 (88% of sweep) | 340 | 1132 |
Wordmark (text-reveal, 0.62s) | 882 | 620 | 1502 |
animationComplete emitted | — | — | 1502 |
Total: 1.50s, against 2.08s on master (visual end; master emits at 2.26s, 180ms after everything has stopped moving). Panel A of the mockup runs both side by side; the GIFs carry it.
Neither 1.36s nor 2.08s→1.36s is right. Measured with the same "last thing stops moving" definition the issue used to get 2.08s for master, v12 lands at 1502ms. The doc's 1.36s counts the 340ms dot tail but not the 620ms wordmark reveal that starts after it. Report the measured number in the PR body, not either published figure — same caution Orion applied to the Journey total, and it was warranted twice.
1.2 Constants
- const REVEAL_DURATION = 1200;
+ const REVEAL_DURATION = 900;
- const DOT_DELAY = 80;
+ const DOT_DELAY = -108; // fires at 88% of the sweep
- const DOT_POP_DURATION = 380;
+ const DOT_POP_DURATION = 340;
- const TEXT_DELAY = DOT_DELAY + 180;
+ const TEXT_DELAY = DOT_DELAY + 90;
+ const TEXT_REVEAL_DURATION = 620; // keep in sync with the .text-reveal keyframe
+ const REVEAL_TOTAL = REVEAL_DURATION + TEXT_DELAY + TEXT_REVEAL_DURATION; // 1502
- headDeg.value = easeOutCubic(t) * REST_SWEEP;
+ headDeg.value = easeOutQuint(t) * REST_SWEEP; @keyframes text-reveal {
- 0% { opacity: 0; transform: translateX(-10px) scale(0.96); }
+ 0% { opacity: 0; transform: translateX(-6px) scale(0.985) skewX(-8deg); }
+ /* 100% must also carry skewX(-8deg) — see the wordmark spec §2.3 */
}- .dot-pop { animation: dot-pop 0.38s …; }
+ .dot-pop { animation: dot-pop 0.34s …; }easeOutQuint settles the head harder than easeOutCubic, which is what lets 900ms read as decisive where 1200ms of cubic drifts. Travel 10px → 6px and scale 0.96 → 0.985 keep the wordmark from appearing to arrive separately — it is already almost in place when it becomes visible.
1.3 BLOCKER — DOT_DELAY = -108 cannot be implemented literally
In both reveal functions the dot is scheduled with setTO(…, DOT_DELAY) inside the else branch that runs after the rAF loop has already finished (ObjectuveLogo.vue:441 and :481). A negative setTimeout delay clamps to 0, so the dot would fire at 900ms — the instant the sweep ends. The dead beat disappears; the overlap the whole change is asking for never happens, and every constant in the diff looks correct.
Required structure: schedule from t0, not from loop end.
const t0 = performance.now();
// … rAF loop for the sweep only …
setTO(() => { dotPopping.value = true; dotOpacity.value = 1; }, REVEAL_DURATION + DOT_DELAY); // 792
setTO(() => { wordmarkHidden.value = false; wordmarkAnimating.value = true; },
REVEAL_DURATION + TEXT_DELAY); // 882
setTO(() => { dotPopping.value = false; wordmarkAnimating.value = false; completeCycle(); },
REVEAL_TOTAL); // 1502Firing the dot from inside the tick when t >= 0.88 is equally acceptable. What is not acceptable is any arrangement whose scheduling starts at the end of the rAF loop.
Verify by measurement, not by reading the constant. A clamped-to-zero negative delay is a fail even though the source says -108.
1.4 Stall watchdog
scheduleStallWatchdog(expected) fires settleToRest() at expected × 2. Derive expected from the spec'd total, not from a sum of deltas — a negative DOT_DELAY inside an ad-hoc sum silently shortens the window.
scheduleStallWatchdog(REVEAL_TOTAL); // 1502 → watchdog at 3004ms, 1502ms of headroomBoth call sites (:431 and :466) use REVEAL_TOTAL. Minimum acceptable headroom is 1× the real duration; this gives exactly that.
1.5 BLOCKER — reveal-breath: the breath stops being a pause
SignIn.vue:23 renders animate="reveal-breath", which routes to startRevealBreathAnimation (:459) — a second copy of the reveal with BREATH = 520 inserted between the sweep and the dot. The issue's diff only touches startRevealAnimation. Both functions read the same four constants, so both would speed up, but sign-in — the most-seen instance of this animation in the product — would keep a 520ms hole in the middle of it. That hole is the exact thing this pass exists to remove.
Ruling: keep the swell, drop the gap.
- const BREATH = 520; // a beat inserted between the sweep and the dot
+ const BREATH = 300; // a ring-scale swell running *under* the dot and wordmark- The breath becomes a 300ms ring-scale pulse from t = 900 to t = 1200, concurrent with the dot pop (792–1132) and the wordmark (882–1502). It is texture on the ring's landing, not a beat of its own.
ringScaledrives only the<g>containing the arc and taper — the dot is outside it and is unaffected, which is what makes the overlap read cleanly.- Keep the existing
1 + 0.018 * sin(bt * π)shape. - Dot and wordmark keep the same absolute offsets as
reveal— 792 and 882. The two variants differ only by the ring's swell. reveal-breathtotal = 1502ms, identical toreveal. Sign-in gets the same pacing as everywhere else. Report it separately in the PR body anyway.
Panel B of the mockup runs master's 2.60s breath variant against this one.
1.6 animate="origin" — untouched, but not forgotten
SignUp.vue:71 uses animate="origin" (not reveal-breath), a third terminating reveal at ~2.95s with its own timeline. Not retimed in this pass — it is a first-launch story with a different job, and nothing in the design pass covers it. It is in scope for the reduced-motion guard (§3) and for the single-emit invariant (§4).
1.7 Mono dot settle
When accent="mono" (wordmark spec §2.4), the dot still pops gold and cross-fades to #ffffff over 200ms starting at the end of the pop (1132 → 1332), landing before the wordmark settles. Mono is a resting treatment; the reveal keeps its warmest moment. Opacity/colour only — no extra transform, and no change to any offset above.
2. The Journey sequence
2.1 Constants
- const P1_DURATION = 1500; const P2_DURATION = 1200;
- const P3_DURATION = 900; const P3_PAUSE = 200;
- const P4_DURATION = 2200;
+ const P1_DURATION = 1000; const P2_DURATION = 900;
+ const P3_DURATION = 420; const P3_PAUSE = 120;
+ const P4_DURATION = 1100;
// P1 easing — put the strike in the first 20%
- const eased = easeInOutCubic(t);
+ const eased = easeOutQuart(t);
- const RAY_ANGLES = Array.from({ length: 16 }, (_, i) => (i * 360) / 16);
+ const RAY_ANGLES = Array.from({ length: 12 }, (_, i) => (i * 360) / 12);
- const PARTICLE_LIFE = 700;
+ const PARTICLE_LIFE = 520;- .dot-pop-j { animation: dot-pop-j 0.46s …; }
+ .dot-pop-j { animation: dot-pop-j 0.40s …; }
// and the timeout that gates on it, at the end of `rebuilding`:
- window.setTimeout(() => { …; emit('complete'); }, 480);
+ window.setTimeout(() => { …; emit('complete'); }, 400);That last one is easy to miss and it is what reconciles the two published totals: the JS timeout must track the keyframe, or the sequence carries an 80ms tail of nothing.
2.2 Why the easing move is the important part
easeInOutCubic reaches the halfway point of the sweep at exactly t = 0.5 — so the dot collision, the ignition, the single most eventful frame in the whole sequence, landed at the slowest, most inert moment of the phase. easeOutQuart reaches it at:
1 − (1 − t)⁴ = 0.5 → t = 0.1591 → 159ms into P1The ignition becomes the entrance. Everything after it is aftermath, which is the correct shape for a celebration.
The rebuild halving is the same argument: 2200ms redrawing a ring the user watched complete four seconds earlier was over a third of the sequence. 1100ms of easeOutQuint covers the same distance and still lands soft.
16 → 12 rays: at 16 the spokes read as a stock sunburst; at 12 the dashes stay legible and the two concentric waves carry the expansion. Particle life 700 → 520 so the motes clear before the waves do.
2.3 Measured total
| Phase | master | v12 |
|---|---|---|
| P1 complete | 1500 | 1000 |
| P2 radiate | 1200 | 900 |
| P3 fade + pause | 1100 | 540 |
| P4 rebuild | 2200 | 1100 |
| Dot pop tail | 480 | 400 |
| Total | 6.48s | 3.94s |
Plus onMounted's 400ms autoplay delay → 4.34s from mount.
This reconciles the disagreement Orion flagged: the design doc's 3.94s is correct (it assumes the 400ms tail); the project sync note's 2.94s is wrong. Measure it anyway and report the measured figure — do not report it against DISMISS_MS, per §0.
Panel D of the mockup lays both timelines out.
3. Reduced-motion still path
Import the existing composable — read-only, no changes to it:
import { useReducedMotion } from '@/composables/useAccessibility';
const { reducedMotion } = useReducedMotion();3.1 ObjectuveLogoAchieved.vue — playStill()
function play() {
if (reducedMotion.value) return playStill();
…
}| Beat | Window | What happens |
|---|---|---|
| Ring in | 0 → 160ms | The completed gold ring (FULL_SWEEP, colorBlend = 1) fades from 0 → 1 opacity. No sweep — the geometry is set to complete before the first frame. |
| Hold | 160 → 1060ms | Nothing moves. |
| Cross-fade | 1060 → 1260ms | The resting mark (REST_SWEEP + taper + dot, blue) fades in over the gold ring; the gold ring is removed once the rest layer is opaque. |
| Complete | 1260ms | resetState(), then emit('complete') — exactly once. |
Total 1.26s.
May animate: opacity. Nothing else. Must not animate: the arc sweep, ringScale, the burst, particles, the two waves, the bloom, the glow filter, the dot pop, the ignition flash. Set every one of those to its inert value before the first frame — taperPath = '', dotVisible = false (the dot has been consumed), ringScale = 1, burstActive = false, particlesActive = false, wave1Opacity = wave2Opacity = 0, bloomOpacity = 0, glowActive = false.
No dip to empty. The cross-fade is a dissolve, not a fade-out-then-in — fading the ring to zero and back reads as a blink, which is a motion artefact in a path whose whole purpose is not to have any. An extra static layer in the template for the resting mark is the expected way to do this and is fine.
ringOpacity already exists (:249) and is already bound to the arc and taper paths, so the fade-in needs no new reactive state — only the rest layer does.
The still path keeps the meaning: the ring completes, it turns gold, it returns to rest. It just drops every transform.
3.2 ObjectuveLogo.vue — the equivalent guard
Applies to all three terminating modes: reveal, reveal-breath, origin.
function startRestState() {
clearTimers();
resetAll();
cycleComplete = false; // so settleToRest()'s guard doesn't early-return
animationInFlight.value = true;
settleToRest(); // already emits exactly once, via completeCycle()
}Routing it through the existing settleToRest() is deliberate: that function already produces the exact rest geometry a completed reveal reaches, and already funnels through completeCycle()'s once-only guard (:391-396). Do not add a second emit path — a new one is how the once-only invariant gets broken.
- No tween, no
.text-reveal, no.dot-pop. The mark and wordmark are simply present. - Emits in the same tick as mount. Consumers bind
@animation-completebefore mount, so it is received. loadingandcoach-thinkingare out of scope and keep animating under reduced motion. They are continuous progress indicators, they never emit a completion event, so there is no deadlock risk — but replacing a spinner is its own decision. Recorded in §7.
4. The invariant
Both components emit their completion event exactly once, on every path, including the reduced-motion path.
Paths that must satisfy it:
| Component | Event | Paths |
|---|---|---|
ObjectuveLogo | animationComplete | reveal, reveal-breath, origin, each under normal and reduced motion; plus the stall watchdog and the visibilitychange settle, which can race the normal completion |
ObjectuveLogoAchieved | complete | play() and playStill() |
ObjectuveLogo's guard already exists and is correct; keep completeCycle()'s early return intact and do not introduce a parallel emit. ObjectuveLogoAchieved has no guard today — its emit sits inline in the rebuilding branch's timeout. Add the same shape: a module-scoped cycleComplete flag reset at the top of play()/playStill() and checked before emitting.
Correction to the issue's stated risk: "SignIn.vue gates the form on animationComplete, so the still path must still emit it" is already mitigated at the call site — SignIn.vue:365-367 reads prefersReducedMotion, passes animate="none", and pre-sets logoRevealed = true. SignUp.vue:70-72 does the same with formRevealed. So the two consumers that would deadlock already guard themselves. The invariant still holds, and the new in-component guard must not double-handle or regress either call site — confirm both still reveal their form instantly under reduced motion.
4.1 Consumer audit — required in the PR body
Ten consumers. Only the first two bind the completion event at all; the audit exists so that stays true and stays deliberate.
| Consumer | Binds completion? | Reduced-motion exposure |
|---|---|---|
SignIn.vue | ✅ logoRevealed | Self-guarded (animate="none" + pre-set) |
SignUp.vue | ✅ formRevealed | Self-guarded, animate="origin" |
SsoCallback.vue | ✗ | animate="loading" — looping, out of scope |
AdminRedirect.vue | ✗ | animate unset → none |
WelcomeIntro.vue | ✗ | ObjectuveLogoAchieved, desktop only — the surface §3.1 fixes |
AppHeader.vue | ✗ | animate unset → none |
SideMenu.vue | ✗ | Check both instances |
LoadingAnimation.vue | ✗ | animate="loading" — looping, out of scope |
GoalForm.vue | ✗ | animate="coach-thinking" — looping, out of scope |
CoachChatPanel.vue | ✗ | animate="coach-thinking" ×2 — looping, out of scope |
AchievementToast.vue is listed on the issue's affected-files list but renders no logo component. Nothing to do there.
5. Storybook story brief
ObjectuveLogo.stories.ts
| Story | Purpose |
|---|---|
Reveal / RevealBreath | Existing; re-snapshot at the new pacing |
RevealReducedMotion | Decorator forcing prefers-reduced-motion: reduce; asserts the rest state appears with no tween |
RevealMono | accent="mono" — the gold-to-white dot settle (§1.7) |
ObjectuveLogoAchieved.stories.ts
| Story | Purpose |
|---|---|
Default | Existing; re-snapshot at 3.94s |
StillPath | Reduced-motion decorator; the 1.26s opacity-only path |
argTypes: size, variant, autoplay, loop.
6. Test strategy
Vitest — extend ObjectuveLogo.spec.ts and ObjectuveLogoAchieved.spec.ts:
- Exactly one
animationCompleteper cycle onreveal,reveal-breathandorigin, under normal and reduced motion (six cases). - Exactly one
completeper cycle fromplay()and fromplayStill(). playStill()leaves every transform-driven ref at its inert value for the whole path — assertringScale,burstActive,particlesActive, wave opacities andbloomOpacitynever change.- The stall watchdog fires only as a fail-safe: with fake timers, advancing to
REVEAL_TOTALemits once and advancing pastREVEAL_TOTAL * 2does not emit again. - The dot's absolute firing time, not the constant's value — advance fake timers to 791ms and assert the dot has not popped; to 793ms and assert it has. This is the test that catches the clamped-negative-delay failure mode, and it is the one that must exist.
Cypress — npm run test:e2e:auth: the sign-in form becomes interactive under both normal and reduced motion.
Measurement, required in the PR body — instrumented totals for reveal, reveal-breath and the Journey sequence, as numbers. Three published figures already disagree; a fourth unmeasured one helps nobody.
7. Follow-ups recorded, not fixed here
loadingandcoach-thinkingignoreprefers-reduced-motion. Both loop indefinitely.LoadingAnimation.vue,SsoCallback.vue,GoalForm.vueandCoachChatPanel.vueshow them as progress indicators, so they can't simply be stopped — they need a designed static or opacity-only substitute. Real gap, separate decision.ObjectuveLogoAchievedis desktop-only and has one consumer. Worth asking whether the sequence should appear on mobile onboarding at all, now that it's 3.94s instead of 6.48s. Product question for Orion, not a design one.
8. Acceptance criteria for Codi
- [ ] Reveal constants per §1.2, and the dot demonstrably fires at ~792ms — proven by test or instrumentation, not by the presence of
-108. A clamped-to-zero negative delay is a fail. - [ ]
easeOutQuinton the reveal head;.text-reveal0% istranslateX(-6px) scale(0.985)and both keyframe stops carryskewX(-8deg);.dot-popis 0.34s. - [ ]
startRevealBreathAnimationper §1.5 —BREATH = 300, concurrent, no gap; dot and wordmark at the same absolute offsets asreveal; total equal toreveal's. - [ ] Both reveal functions behaviourally consistent. This is the trap in this task: the issue's diff only shows one of them.
- [ ]
scheduleStallWatchdog(REVEAL_TOTAL)at both call sites (:431,:466). - [ ] Journey constants per §2.1 including the 480 → 400 timeout that pairs with
dot-pop-j;easeOutQuarton P1; 12 rays;PARTICLE_LIFE = 520. Geometry, HSL blending, bloom, wave radii and ignition order unchanged. - [ ] Three measured totals in the PR body — reveal, reveal-breath, Journey. Not stated against
DISMISS_MS(§0). - [ ]
playStill()per §3.1: opacity only, 1.26s, no dip to empty, every transform-driven ref inert. - [ ]
ObjectuveLogoreduced-motion guard on all three terminating modes, routed through the existingsettleToRest()/completeCycle()pair — no second emit path. - [ ]
ObjectuveLogoAchievedgains a once-only emit guard. - [ ] Ten-consumer audit table in the PR body;
SignIn.vueandSignUp.vueconfirmed not double-handled and still instant under reduced motion. - [ ] Vitest cases per §6, including the 791/793ms assertion; stories per §5; both spec files pass; lint and typecheck clean.
- [ ] UI evidence: motion GIFs of
revealandreveal-breathin light and dark, plus a reduced-motion capture of both components.
9. Open questions
None blocking. §0 corrects the framing rather than asking about it; §7's two items are recorded for later.