Skip to content

UI-SPEC — Launch interstitial Journey sweep

Issue: OBJ-2459 · Task: 1 of 5 (Orion's package) · Author: Desi · Date: 2026-08-11

Implementation update (OBJ-2774, 2026-08-21): the mechanism this spec originally locked in — a pure-CSS stroke-dasharray sweep + rotating taper polygon + an always-present low-opacity "rest track" — has been replaced. Both index.html and ObjectuveLogoJourney.vue now reproduce ObjectuveLogo.vue's real animate="reveal" motion exactly: a requestAnimationFrame tick driven by the true easeOutQuint formula (1 - (1-t)^5, not its cubic-bezier(0.22, 1, 0.36, 1) CSS approximation), with the arc and taper <path> d attributes recomputed every frame via buildArcPath/buildTaperPath (ported byte-for-byte from ObjectuveLogo.vue, the single source of truth) rather than animated via CSS. ObjectuveLogoJourney.vue no longer reimplements the geometry at all — it delegates to <ObjectuveLogo variant="icon" theme="light" animate="reveal"> directly. The rest track described in D1 below has been removed (accepted tradeoff: an empty first frame at the native-splash → interstitial handoff, rather than a CSS-only track that couldn't reproduce the true taper geometry). The sections below are left as the historical record of the original decision; where they now conflict with the above, the callout wins. See ObjectuveLogo.vue for the locked geometry/timing constants and the drift test in ObjectuveLogoJourney.spec.ts for what's actually asserted today.

Scope

Superseded in part (OBJ-2774): the "pure CSS / hard zero-JS" constraint below no longer holds. The interstitial now runs a small inline <script> before main.ts — CSS cannot interpolate a filled sector's d or the concave taper's Bézier control points, so reproducing ObjectuveLogo.vue's real reveal required a per-frame d recomputation. The rest of this section's intent (paint on the WebView's first frame, no GSAP, no bundle dependency) still holds.

Port the about-page logo mark animation (marketing_landing/about.html, .about-mark-animation) into the mobile launch interstitial (ionic_frontend/index.html), re-expressed as pure CSS on inline SVG — the interstitial has a hard zero-JS constraint (docs/development/launch-interstitial-pattern.md) because it must paint on the WebView's first frame, before main.ts runs. GSAP cannot come along.

Also covers the Storybook taxonomy split: the sweep becomes UI/ObjectuveLogoJourney; the existing completion celebration (today misnamed ObjectuveLogoJourney) becomes UI/ObjectuveLogoAchieved.

Surfaces: the launch interstitial (all platforms), and the new ObjectuveLogoJourney.vue component that mirrors it 1:1 for Storybook. Backs Orion's Tasks 2, 3, and 4.

Component / screen inventory

SurfaceStatus
ionic_frontend/index.html launch interstitialmodified — static mark + opacity pulse → animated sweep, then pulse
ObjectuveLogoJourney.vue (new)new — pure-CSS mirror of the interstitial (OBJ-2774: now delegates to ObjectuveLogo instead of mirroring)
ObjectuveLogoJourney.vue (existing celebration)renamedObjectuveLogoAchieved.vue, no behaviour change
ObjectuveLogo.vuereused, untouched — its animate="reveal" is the gesture this spec inherits its timing from
Native splash PNGs (android/.../splash.png, ios/.../Splash.imageset/)untouched — see D1

The two locked values

Everything else in this spec is derived from these. Codi copies them verbatim into both index.html and ObjectuveLogoJourney.vue; the drift test in Orion's Task 4 asserts both files carry them.

NameValueWhere it comes from
Sweep duration900msObjectuveLogo.vue:380 REVEAL_DURATION = 900 — the app's own locked reveal
Sweep easing1 - Math.pow(1-t, 5) (true easeOutQuint)ObjectuveLogo.vue:409. Updated by OBJ-2774: both index.html and ObjectuveLogoJourney.vue now drive the sweep with this exact formula on a requestAnimationFrame tick, not the cubic-bezier(0.22, 1, 0.36, 1) CSS approximation this spec originally locked — the approximation is close but not pixel-identical, and "identical" was the bar.

Two derived constants, also copied verbatim:

NameValueWhere it comes from
Dot delay792ms900 + DOT_DELAY(−108)ObjectuveLogo.vue:381, the same 88%-of-sweep landing
Dot pop340ms cubic-bezier(0.34, 1.56, 0.64, 1).dot-pop, ObjectuveLogo.vue:838-847 — locked celebratory overshoot curve (DESIGN.md Motion)

Visual end = 1132ms (792 + 340). The ambient pulse starts there.

Why 900ms easeOutQuint, not 1800ms power3.out

Orion's Q2 suggested approximating the about page's GSAP power3.out (≈ easeOutCubic). Deviating deliberately:

  • The about page's 1800ms is a scroll-triggered marketing beat with room to breathe. A launch interstitial has a ~0.5s median before Vue mounts over it. 1800ms would never finish.
  • logo-v12-motion.md §1.2 records that easeOutCubiceaseOutQuint was a deliberate change: "easeOutQuint settles the head harder than easeOutCubic, which is what lets 900ms read as decisive where 1200ms of cubic drifts." Re-introducing cubic here would re-introduce the drift that change removed.
  • Throughline payoff: the interstitial's sweep and the app's own ObjectuveLogo animate="reveal" (sign-in, WelcomeLayout) then run at identical tempo and curve. Launch → first screen reads as one gesture continued, not two apps.

cubic-bezier(0.22, 1, 0.36, 1) is the standard CSS form of easeOutQuint; it is not a new curve, it is the existing locked curve expressed in the only language the interstitial can speak. Recorded as a vocabulary addition (CSS form of an existing entry) in references/motion-vocabulary.md, not an invention — see Throughline findings.


Decisions (Orion's open questions)

D1 — Native-splash seam (Q1): resolved, not accepted — superseded by OBJ-2774

Superseded (OBJ-2774): the rest track described below has been removed. Once the arc/taper became per-frame-recomputed <path> geometry (to reproduce ObjectuveLogo.vue's real taper exactly, including the concave curve that grows in from a point), a second static copy of that same geometry sitting underneath it stopped being a three-element convenience and became a second geometry implementation to keep in sync — the thing this whole port exists to avoid. Accepted tradeoff: the interstitial's first frame (before the first requestAnimationFrame callback paints) is now empty rather than a low-opacity silhouette. This was an explicit, discussed tradeoff, not an oversight — see the OBJ-2774 callout at the top of this doc.

Orion recommended accepting a visible seam (OS shows the finished mark → interstitial starts from an empty ring → redraws), and deferring native-PNG regeneration to a follow-up.

Original decision (now superseded): neither. The seam is removed with a rest track — the completed mark, painted at low opacity from frame 0 and never animated, sitting under the live arc.

native splash (solid mark) → interstitial frame 0 (same mark @ 16%) → arc re-lights over it → app

The silhouette is continuous across the handoff; only the fill changes. No frame ever reads as empty, so complete → empty → redrawing never happens. Capacitor here has no SplashScreen plugin config, so the native → WebView handoff is a hard cut with no fade to hide a pop — which is exactly why the silhouette has to survive it.

The track is the existing static mark geometry at opacity: 0.16 (light) / 0.18 (dark), using the same #objectuve-gradient and the same gold — it introduces no new color, only a new opacity stop. A progress-ring track is a familiar idiom, and it costs three elements of inline SVG.

Consequence: regenerating ~10 density variants of the native PNG to an empty/partial ring is no longer needed. Recommend not opening that follow-up. Maggie's call.

D2 — One-shot or loop (Q2): one-shot, then the existing pulse

Sweep plays once. The existing 2.2s launch-splash-pulse gets animation-delay: 1132ms so it only takes over once the mark has landed. Before 1132ms the pulse contributes nothing (no backwards fill); after, it behaves exactly as it does today.

  • Fast boot (~0.5s median): the user sees the sweep ~55% drawn, then the app. That is the mark arriving, cut short — which reads fine, because the track means it never looked broken.
  • Slow boot: sweep resolves, then the ambient pulse signals life indefinitely. Unchanged from today.
  • No looping sweep. A launch surface that re-draws itself every second is a spinner pretending to be a brand mark, and it fights objectuve-compass's no-engagement-decoration line.

D3 — WelcomeIntro.vue (Q3): mechanical rename only, no behaviour change

Agreed with Orion. WelcomeIntro renders ObjectuveLogoAchieved after the rename.

Design note for later, not this package: a completion celebration on the first onboarding slide is the wrong beat — the user has achieved nothing yet. The sweep (new Journey) is the correct gesture there. Recorded as a follow-up.

D4 — admin_dashboard ClerkLoading (Q4): rename now, swap later

Agreed. Rename to ObjectuveLogoAchieved for consistency in this package. Design note, not this package: naming a loading spinner Achieved is semantically wrong, and the new Journey is the right component for a loading state. Recorded as a follow-up.

D5 — Storybook naming: confirmed

  • UI/ObjectuveLogoJourney — the sweep. "The mark drawing itself. Progress accumulating."
  • UI/ObjectuveLogoAchieved — the 360° gold completion + burst + rebuild. "A completion. The rare one."

Sidebar reads ObjectuveLogoObjectuveLogoAchievedObjectuveLogoJourney, which groups correctly. The names now match what each component does.


Layout

Unchanged from today. Mark centred in a full-bleed themed surface, safe-area padded.

┌──────────────────────────────┐  env(safe-area-inset-*)
│                              │
│                              │
│            ◜◝                │   96px mobile
│           ◟  ◞               │   128px ≥768px
│                              │
│         (sr-only:            │
│      "Loading Objectuve")    │
└──────────────────────────────┘

Geometry

Superseded by OBJ-2774. The table below described the original stroke-dasharray + rotating-polygon construction. The mark is now a filled donut sector (buildArcPath) plus a concave gold taper (buildTaperPath), both ported verbatim from ObjectuveLogo.vue and recomputed on every animation frame — not a fixed-shape element animated by CSS. See ObjectuveLogo.vue's polarToXY/buildArcPath/buildTaperPath/easeOutQuint for the actual geometry, and the inline <script> in index.html for its hand-port (index.html cannot import the component — it paints before /src/main.ts loads). The trap below (no element transform: rotate() on the arc, because #objectuve-gradient is userSpaceOnUse) still holds and is more load-bearing now, since per-frame d recomputation is exactly how that trap is sidestepped.

The current mark is a donut path with rO=42 / rI=28, plus a 10° gold taper, plus the dot. Expressed for animation (original, superseded construction — kept for history):

ElementConstructionWhy
Arc<path d="M 50 15 A 35 35 0 1 1 15.5317 56.0777" fill="none" stroke="url(#objectuve-gradient)" stroke-width="14"/>r=35 / width=14 is exactly the rO=42 / rI=28 donut. The path starts at 12 o'clock and sweeps 260° clockwise, so no element transform is needed — see the trap below.
Arc animationstroke-dasharray: 158.83, stroke-dashoffset 158.83 → 0The path is exactly 260° of r=35 = 2π·35 × 260/360 = 158.83 long, so a single-value dasharray covers it exactly. The taper owns the remaining 10°, summing to the mark's 270°.
Taper<g class="splash-taper"><polygon points="9,57 15,50 22,55"/></g>, rotating −260deg → 0deg about the viewBox centreThe taper is rotationally invariant on a constant-width ring, so a fixed polygon rotated on the same curve keeps head and tip locked — no per-frame path recomputation. Safe to transform: it's a flat gold fill, not a gradient.
Dot<circle cx=23 cy=23 r=5.5 fill="#fcc419">, transform-origin: 23px 23pxUnchanged geometry; only the pop is new. Still current — the dot is untouched by OBJ-2774.
TrackThe existing full donut d + taper polygon + dot, in one <g class="splash-track">Removed by OBJ-2774 — see D1 above.

Trap: do not build the arc as <circle> + transform="rotate(-90)"

That is the obvious construction (and the one Orion's task sketch suggests), and it is wrong here. #objectuve-gradient is gradientUnits="userSpaceOnUse", which resolves in the element's user space — so an element transform rotates the brand gradient with it. A rotate(-90) on the arc puts the gradient 90° off its shipped axis.

Measured, at 512×512 against the mark shipping in index.html today:

ConstructionPixels differing from the shipped mark
<circle> + transform="rotate(-90 50 50)"20.3% — gradient rotated 90°
<path> starting at 12 o'clock, no transform1.5%, confined to a 1px anti-aliasing rim, zero interior divergence

The residual 1.5% is the unavoidable rasterization difference between a filled path and a stroked path along their shared boundary — it is not a geometry error. Ray-scanning both renders from the centre at nine angles gives inner radius 27.95–28.07 and outer 41.92–42.10 for the spec construction against 27.45–28.48 / 41.65–42.42 for production (the wider production spread is scan quantisation, not shape), with the gap in the same quadrant in both.

Evidence script: docs/ui-specs/mockups/logo-journey-splash/verify-rest-geometry.mjs.

Verification for Codi: the rest state must be visually indistinguishable from today's mark. Re-run that script after implementing; a jump back toward 20% means the transform crept back in.


The construction rule that must not be inverted

Updated by OBJ-2774. The arc and taper are no longer CSS-animation-driven, so "base CSS is the rest state" no longer applies to them — there's no CSS animation property on .splash-arc/.splash-taper to fall back to a base value. Reduced motion for the arc/taper is now handled by the inline <script> itself: it checks matchMedia('(prefers-reduced-motion: reduce)') before starting its requestAnimationFrame loop and, if set, paints the completed mark (head = REST_SWEEP = 270) directly on the first frame instead of ticking through the sweep. The rule below still governs the dot, which is still CSS-animation-driven and unchanged.

Base CSS is the rest state for every element still animated via CSS (today: only the dot; the pulse only ever animates opacity around 1, so it has no meaningful "empty" base to invert). Dot at full scale and opacity by default; its keyframes start from the empty state and animate toward the base.

This matters because under prefers-reduced-motion: reduce we set animation: none — and animation: none renders the element's base value. If the base were the empty state (the intuitive way to write this), every reduced-motion user would get a permanently blank dot on every launch. The failure is invisible in normal testing.

Corollary Codi must keep:

  1. .splash-dot needs animation-fill-mode: backwards. Its animation is delayed 792ms; without backwards the dot sits at its base (full size, full opacity) for the first 792ms and then snaps to scale(0) to pop. With backwards it holds the 0% frame through the delay.

The interstitial cannot rely on App.vue's global reduced-motion reset (App.vue:313-322) — that CSS hasn't loaded yet at first paint. Its own inline CSS block (dot) and inline script (matchMedia check, arc/taper) are the only guards, and both must independently paint a finished-looking mark, never an empty one.


Design tokens

The interstitial predates the token pipeline and is inline-literal by necessity — it cannot import design_system/css/tokens.css without a network request before first paint. Every literal below is inherited unchanged from the current index.html; this spec mints no new color.

SurfaceLiteral (inline, inherited)Canonical token it expresses
Mark gradient#177bbb → #05cbfcbg-gradient-primary (--primary hsl(203 78% 41%))
Taper + dot#fcc419--gold hsl(43 96% 56%)
Surface, lightradial-gradient(circle at 50% 45%, #ecf3f8 0%, #f8fafc 60%)--background hsl(210 40% 98%)
Surface, darkradial-gradient(circle at 50% 45%, #0d1f33 0%, #0b111e 60%)dark --background
html, body#f8fafc light / #0b111e darkas above — asserted by launch-splash.spec.ts, do not touch
Rest trackno new coloropacity: 0.16 light / 0.18 dark on the abovenew opacity stop only

ObjectuveLogoJourney.vue ships inside the bundle and should use the Tailwind token classes for anything outside the SVG. The SVG's own fills stay literal so the drift test can compare the two files' constants directly.


Typography

None. The interstitial carries no visible text by design — web fonts aren't loaded at first paint, so any string would FOIT. The only text is the .sr-only label.


State catalog

Launch interstitial

Updated by OBJ-2774: no rest track (removed, see D1), and the sweep is now driven by real easeOutQuint on requestAnimationFrame, not cubic-bezier(0.22, 1, 0.36, 1). Frame 0 is empty (accepted tradeoff), not a low-opacity track.

  • Default (0 → 900ms): frame 0 is empty (no rest track) until the first requestAnimationFrame callback paints. The arc/taper d is then recomputed every frame per easeOutQuint(elapsed/900) × 270°, drawing the gold taper leading the arc head clockwise from 12 o'clock — the concave taper grows in from a point rather than rotating in at full size, matching ObjectuveLogo.vue's animate="reveal" exactly.
  • Dot landing (792 → 1132ms): gold dot pops scale(0) → 1.28 → 1 on the locked overshoot curve, overlapping the last 108ms of the sweep. The overlap is the point — the dot lands as the arc settles, not after it. Unchanged by OBJ-2774.
  • Rest (1132ms →): completed 270° mark. The 2.2s opacity pulse takes over, 1 → 0.72 → 1, indefinitely. ObjectuveLogoJourney.vue (Storybook) has no pulse — deliberate asymmetry, since the pulse is ambient boot-liveness signaling and Journey isn't a boot surface.
  • Loading: this is the loading state. There is no separate one.
  • Empty: n/a — the surface is never data-driven.
  • Error: n/a at this layer. A boot failure is handled downstream by main.ts's catch, which overwrites #app with the data-testid="bootstrap-error" card. The interstitial is replaced either way.
  • Success / exit: app.mount('#app') replaces #app's innerHTML. No teardown wiring, no exit animation — an exit transition would need JS and would delay first interactive paint.
  • Disabled / Focus / Hover / Active: n/a — nothing here is interactive. No focusable element, so no focus ring is required or wanted.
  • Dark mode: surface gradient swaps (existing rule). No track opacity delta anymore (track removed). Mark colors are identical in both themes.
  • Motion-reduced: the inline script checks matchMedia before starting its rAF loop and paints the completed mark (head = 270) directly if reduced motion is set, skipping the sweep entirely rather than relying on a CSS animation: none fallback for the arc/taper. The dot and pulse (still CSS-driven) keep animation: none. Result: the completed mark, full opacity, dead still — visually identical to today's reduced-motion state.
  • Mobile / tablet / desktop: 96px below 768px, 128px at and above. Unchanged. The sweep is resolution-independent.

ObjectuveLogoJourney.vue

Updated by OBJ-2774: ObjectuveLogoJourney.vue no longer implements this state catalog itself — it delegates entirely to <ObjectuveLogo variant="icon" theme="light">, passing animate="reveal" when autoplay is true and animate="none" otherwise. Reduced motion is not re-decided in Journey; ObjectuveLogo's own startAnimationForMode() already routes a reveal request to rest when prefers-reduced-motion is set, so Journey inherits that guard, plus ObjectuveLogo's stall watchdog and visibilitychange fail-safe, for free.

  • autoplay: false: passes animate="none" to ObjectuveLogo, which renders rest state, no animation.
  • Storybook ReducedMotion story: overrides matchMedia before mount; ObjectuveLogo picks it up internally and renders rest.

Microcopy

One string on the surface, unchanged.

SurfaceStringTone check
Screen-reader labelLoading Objectuvebrief ✅, factual ✅, asserted by launch-splash.spec.tsverbatim, do not reword

Storybook docs strings (new copy):

SurfaceStringTone check
UI/ObjectuveLogoJourney component doc"The mark drawing itself — a 900ms easeOutQuint sweep with the gold taper leading, driven by ObjectuveLogo's own rAF tick (not a CSS approximation)... plays the exact same motion as the cold-launch interstitial... locked together by a drift test." (updated by OBJ-2774; current string lives in ObjectuveLogoJourney.stories.ts)factual ✅, brief ✅, no empty praise ✅
UI/ObjectuveLogoAchieved component doc"The rare one. The arc completes to 360°, turns gold, radiates, then rebuilds to rest. Reserved for genuine completions — not a loading state."action-forward ✅, sets the usage boundary ✅

Accessibility

  • Focus order: unchanged — the interstitial contains no focusable element and is removed before the app renders any.
  • ARIA: role="status" + aria-live="polite" on the container, aria-hidden="true" on the <svg>, .sr-only text carrying the whole accessible name. All four are asserted by launch-splash.spec.ts — preserve verbatim. The <g>/<circle> elements added inside the SVG inherit aria-hidden, so no new ARIA surface is created.
  • Color contrast: the mark is decorative (aria-hidden) and conveys no information — WCAG AA contrast does not apply to it as a non-text element. The html, body and surface values are unchanged from a shipped, already-verified state. The .sr-only text is visually hidden and exempt.
  • Motion-reduced: specified above for every animated element. The interstitial's inline block is the only guard that runs this early.
  • Tap targets: n/a — nothing interactive.
  • Vestibular safety: the sweep is a fill/reveal, not a translate, scale, or parallax. No large-area motion, no flashing. Under 3Hz throughout.

Storybook story brief

Path: ionic_frontend/src/components/ui/ObjectuveLogoJourney.stories.ts · Title: UI/ObjectuveLogoJourney

StoryWhat it shows
DefaultAutoplaying sweep at size: 200
DarkSame mark on a dark decorator surface. Updated by OBJ-2774: there is no variant prop anymore — the mark is unconditionally theme="light" (gold taper/dot, blue gradient arc; forcing it dark via ObjectuveLogo's theme="dark" would change the taper/dot to translucent white, a genuinely different mark, not a background swap). Dark now means "dark background behind the same mark," conveyed purely by the decorator.
SizeComparison96 / 128 / 200 side by side — the two real interstitial sizes plus a Storybook-legible one
ReducedMotionmatchMedia overridden to reduce; must render the completed mark, still. This story is the regression guard for the base-state rule above.
Frames (recommended)The 0 / 240 / 560 / 900 / 1132ms frozen ladder from the mockup — makes timing reviewable without a recording

argTypes: size (number, 40–400, step 10), autoplay (boolean), loop (boolean — default false; the interstitial never loops, so this exists for Storybook inspection only, and its docs string must say so). variant removed by OBJ-2774 — see the Dark row above; the component has zero application usages, so this was an acceptable Storybook-only API break.

Match the sibling prop shape in ObjectuveLogoAchieved.stories.ts. <script setup lang="ts">, interface Props + withDefaults, defineOptions({ name: 'ObjectuveLogoJourney' }).


Flow sequence

FromTriggerToTransition + durationFeedbackReduced-motion fallbackBack-nav
Native splash (finished mark)WebView readyInterstitial frame 0Hard cut (OS-owned, no plugin config). Updated by OBJ-2774: no rest track — frame 0 is empty until the first requestAnimationFrame callback paints (accepted tradeoff, see D1).noneReduced motion: script paints the completed mark directly on the first frame instead of starting the rAF loopn/a (pre-app)
Interstitial frame 0— (on paint)Interstitial restArc sweep 900ms true easeOutQuint (1 - Math.pow(1-t,5)) on requestAnimationFrame, d recomputed per frame — updated by OBJ-2774, was cubic-bezier(0.22, 1, 0.36, 1); taper d recomputed on the same curve (concave, grows from a point); dot pops 340ms cubic-bezier(0.34, 1.56, 0.64, 1) at 792ms, unchangednone — no toast, no haptic; there is no user action to confirmscript skips the rAF loop and paints the completed mark; dot keeps animation: none → completed markn/a
Interstitial rest1132ms elapsedAmbient pulselaunch-splash-pulse 2.2s ease-in-out infinite, delayed 1132msnoneanimation: none → static markn/a
Interstitial (any point)app.mount('#app')First app screenHard cut — innerHTML replacement. No exit animation: one would require JS and would delay interactive paint, which is the entire reason this surface exists.noneidenticaln/a
Interstitial (any point)bootstrap() throwsdata-testid="bootstrap-error" cardHard cut — same innerHTML patherror card owns its own copyidenticaln/a

No haptic, no toast anywhere in this sequence. Both useHaptics and useToast are Vue-layer composables that do not exist yet at first paint, and there is no user action to acknowledge — firing either would be feedback for something the user didn't do.


Acceptance criteria for Codi

List updated by OBJ-2774 to match the mechanism actually shipped — CSS-approximation and rest-track items replaced with per-frame d recomputation and no-track items.

  • [ ] Renders at 375px width without horizontal scroll
  • [ ] index.html gains zero new network requests before first paint. (No longer "zero JS" — a small inline <script> now hand-ports ObjectuveLogo.vue's geometry/timing, since it can't import the component before /src/main.ts loads; docs/development/launch-interstitial-pattern.md documents this exception.)
  • [ ] Both index.html and ObjectuveLogo.vue carry the true easeOutQuint expression (1 - Math.pow(1-t, 5)), REVEAL_DURATION = 900, and REST_SWEEP = 270 — the drift test in ObjectuveLogoJourney.spec.ts fails if they disagree. (No longer a cubic-bezier(0.22, 1, 0.36, 1) CSS approximation comparison.)
  • [ ] Dot fires at 792ms and pops with 340ms cubic-bezier(0.34, 1.56, 0.64, 1) — same landing as ObjectuveLogo's reveal
  • [ ] Arc and taper d are recomputed every animation frame (not CSS-animated); under prefers-reduced-motion: reduce the inline script paints the completed mark directly on the first frame instead of starting its rAF loop — assert this, it is the spec's highest-risk failure
  • [ ] .splash-dot carries animation-fill-mode: backwards (unchanged)
  • [ ] No rest track (removed by OBJ-2774, see D1) — frame 0 is empty until the first rAF paint; this is an accepted tradeoff, not a defect
  • [ ] Arc built as a filled <path> with no element transform — never transform: rotate() on the arc (rotates the userSpaceOnUse gradient)
  • [ ] Ambient pulse delayed to 1132ms; it never overlaps the sweep; ObjectuveLogoJourney.vue/Storybook has no pulse (deliberate asymmetry — the pulse is boot-liveness signaling, not part of the reveal)
  • [ ] role="status", aria-live="polite", data-testid="launch-splash", the Loading Objectuve sr-only text, and both html, body background values preserved verbatim
  • [ ] launch-splash.spec.ts and ObjectuveLogoJourney.spec.ts pass — the reduced-motion block asserts the contract for the CSS-driven elements (svg pulse, dot) and the script-driven matchMedia check for the arc/taper
  • [ ] tests/smoke-playwright/specs/platform/01-healthy-boot.spec.ts passes untouched
  • [ ] Verified in light and dark, and on a real cold launch on both platforms
  • [ ] Storybook: UI/ObjectuveLogoJourney and UI/ObjectuveLogoAchieved both build; Achieved renders all six existing stories unchanged
  • [ ] Every transition in the Flow sequence table matches the locked motion vocabulary — no invented easing, toast duration, or haptic tier

Throughline check

Journey: cold launch → first app screen (native splash → interstitial → Vue mount). Multi-step, so the gate fires.

GateFindingDisposition
g14 (every async boundary has a designed wait state)Passes. The interstitial is the designed wait state; this change makes it informative rather than decorative.
g15 (skeleton → content swap preserves layout)The native → interstitial handoff had no continuity guarantee, and starting from an empty ring would have broken it outright.Fixed in spec — D1, the rest track
g13 (flow completable under reduced motion)animation: none on an empty base state would strand every reduced-motion user on a blank ring.Fixed in spec — "The construction rule that must not be inverted"
g12 (duration matches declared weight)900ms exceeds DESIGN.md's 200–400ms UI band. Justified: this is a page-level entrance (the 400–500ms "bigger change" band) carrying a brand gesture, and it matches the app's own locked reveal. Recorded rather than silently exceeded.Recorded — no action
g16 (sub-200ms waits don't flash)Median boot ~0.5s cuts the sweep at ~55%. Acceptable because the track means the cut frame still reads as the mark.Recorded — no action
Vocabularyreferences/motion-vocabulary.md has no CSS form of easeOutQuint, and no entry for a pre-Vue paint surface at all — the whole file assumes Vue-layer composables exist.Recorded for Maggie — gap finding, see below

Vocabulary gap for Maggie to route (not minted here): motion-vocabulary.md should gain (a) cubic-bezier(0.22, 1, 0.36, 1) as the CSS form of the locked easeOutQuint, and (b) a "pre-mount surfaces" note stating that index.html's interstitial cannot use App.vue's global reduced-motion reset or any composable, and carries its own inline guard. Both are documentation of what already exists, not new vocabulary — but per the lock statement, extending that file is a decision made outside an audit. Dori's Task 5 is the natural home.

Implementation drift recorded for Maggie (out of scope here):

  • WelcomeIntro.vue renders a completion celebration on the first onboarding slide (D3). Wrong beat; the new Journey is right. One-line follow-up.
  • admin_dashboard/src/App.vue:46 uses the celebration as the ClerkLoading spinner (D4). Wrong beat; the new Journey is right. One-line follow-up.

Open questions

None blocking. D1 deviates from Orion's recommendation (removes the seam rather than accepting it) and, if it holds up on device, makes the native-PNG-regeneration follow-up unnecessary — that is Maggie's call to close out, not mine.

Loading…