Skip to content

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.vue and this doc called it the "Journey sequence" throughout. That component has since been renamed to ObjectuveLogoAchieved.vue — component/file references below have been updated to match. ObjectuveLogoJourney now 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 only

So three things carried through the issue body and the task package are not true, and Codi should not chase them:

  1. DISMISS_MS = 8000 is 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.
  2. Nobody is listening to complete. WelcomeIntro.vue binds 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 is ObjectuveLogo's animationComplete, which SignIn.vue and SignUp.vue do gate on.
  3. 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.

BeatStartsDurationEnds
Ring sweep 0° → 270°, easeOutQuint0900900
Dot pop (dot-pop, 0.34s)792 (88% of sweep)3401132
Wordmark (text-reveal, 0.62s)8826201502
animationComplete emitted1502

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

diff
- 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;
diff
  @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 */
  }
diff
- .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.

ts
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);                                                                            // 1502

Firing 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.

ts
scheduleStallWatchdog(REVEAL_TOTAL);   // 1502 → watchdog at 3004ms, 1502ms of headroom

Both 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.

diff
- 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.
  • ringScale drives 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-breath total = 1502ms, identical to reveal. 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

diff
- 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;
diff
- .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 P1

The 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

Phasemasterv12
P1 complete15001000
P2 radiate1200900
P3 fade + pause1100540
P4 rebuild22001100
Dot pop tail480400
Total6.48s3.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:

ts
import { useReducedMotion } from '@/composables/useAccessibility';
const { reducedMotion } = useReducedMotion();

3.1 ObjectuveLogoAchieved.vueplayStill()

ts
function play() {
  if (reducedMotion.value) return playStill();

}
BeatWindowWhat happens
Ring in0 → 160msThe completed gold ring (FULL_SWEEP, colorBlend = 1) fades from 0 → 1 opacity. No sweep — the geometry is set to complete before the first frame.
Hold160 → 1060msNothing moves.
Cross-fade1060 → 1260msThe resting mark (REST_SWEEP + taper + dot, blue) fades in over the gold ring; the gold ring is removed once the rest layer is opaque.
Complete1260msresetState(), 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.

ts
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-complete before mount, so it is received.
  • loading and coach-thinking are 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:

ComponentEventPaths
ObjectuveLogoanimationCompletereveal, reveal-breath, origin, each under normal and reduced motion; plus the stall watchdog and the visibilitychange settle, which can race the normal completion
ObjectuveLogoAchievedcompleteplay() 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.

ConsumerBinds completion?Reduced-motion exposure
SignIn.vuelogoRevealedSelf-guarded (animate="none" + pre-set)
SignUp.vueformRevealedSelf-guarded, animate="origin"
SsoCallback.vueanimate="loading" — looping, out of scope
AdminRedirect.vueanimate unset → none
WelcomeIntro.vueObjectuveLogoAchieved, desktop only — the surface §3.1 fixes
AppHeader.vueanimate unset → none
SideMenu.vueCheck both instances
LoadingAnimation.vueanimate="loading" — looping, out of scope
GoalForm.vueanimate="coach-thinking" — looping, out of scope
CoachChatPanel.vueanimate="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

StoryPurpose
Reveal / RevealBreathExisting; re-snapshot at the new pacing
RevealReducedMotionDecorator forcing prefers-reduced-motion: reduce; asserts the rest state appears with no tween
RevealMonoaccent="mono" — the gold-to-white dot settle (§1.7)

ObjectuveLogoAchieved.stories.ts

StoryPurpose
DefaultExisting; re-snapshot at 3.94s
StillPathReduced-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 animationComplete per cycle on reveal, reveal-breath and origin, under normal and reduced motion (six cases).
  • Exactly one complete per cycle from play() and from playStill().
  • playStill() leaves every transform-driven ref at its inert value for the whole path — assert ringScale, burstActive, particlesActive, wave opacities and bloomOpacity never change.
  • The stall watchdog fires only as a fail-safe: with fake timers, advancing to REVEAL_TOTAL emits once and advancing past REVEAL_TOTAL * 2 does 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.

Cypressnpm 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

  1. loading and coach-thinking ignore prefers-reduced-motion. Both loop indefinitely. LoadingAnimation.vue, SsoCallback.vue, GoalForm.vue and CoachChatPanel.vue show them as progress indicators, so they can't simply be stopped — they need a designed static or opacity-only substitute. Real gap, separate decision.
  2. ObjectuveLogoAchieved is 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.
  • [ ] easeOutQuint on the reveal head; .text-reveal 0% is translateX(-6px) scale(0.985) and both keyframe stops carry skewX(-8deg); .dot-pop is 0.34s.
  • [ ] startRevealBreathAnimation per §1.5 — BREATH = 300, concurrent, no gap; dot and wordmark at the same absolute offsets as reveal; total equal to reveal'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; easeOutQuart on 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.
  • [ ] ObjectuveLogo reduced-motion guard on all three terminating modes, routed through the existing settleToRest() / completeCycle() pair — no second emit path.
  • [ ] ObjectuveLogoAchieved gains a once-only emit guard.
  • [ ] Ten-consumer audit table in the PR body; SignIn.vue and SignUp.vue confirmed 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 reveal and reveal-breath in 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.

Loading…