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-dasharraysweep + rotating taper polygon + an always-present low-opacity "rest track" — has been replaced. Bothindex.htmlandObjectuveLogoJourney.vuenow reproduceObjectuveLogo.vue's realanimate="reveal"motion exactly: arequestAnimationFrametick driven by the trueeaseOutQuintformula (1 - (1-t)^5, not itscubic-bezier(0.22, 1, 0.36, 1)CSS approximation), with the arc and taper<path>dattributes recomputed every frame viabuildArcPath/buildTaperPath(ported byte-for-byte fromObjectuveLogo.vue, the single source of truth) rather than animated via CSS.ObjectuveLogoJourney.vueno 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. SeeObjectuveLogo.vuefor the locked geometry/timing constants and the drift test inObjectuveLogoJourney.spec.tsfor 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>beforemain.ts— CSS cannot interpolate a filled sector'sdor the concave taper's Bézier control points, so reproducingObjectuveLogo.vue's real reveal required a per-framedrecomputation. 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
| Surface | Status |
|---|---|
ionic_frontend/index.html launch interstitial | modified — 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) | renamed → ObjectuveLogoAchieved.vue, no behaviour change |
ObjectuveLogo.vue | reused, 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.
| Name | Value | Where it comes from |
|---|---|---|
| Sweep duration | 900ms | ObjectuveLogo.vue:380 REVEAL_DURATION = 900 — the app's own locked reveal |
| Sweep easing | 1 - 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:
| Name | Value | Where it comes from |
|---|---|---|
| Dot delay | 792ms | 900 + DOT_DELAY(−108) — ObjectuveLogo.vue:381, the same 88%-of-sweep landing |
| Dot pop | 340ms 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 thateaseOutCubic→easeOutQuintwas a deliberate change: "easeOutQuintsettles the head harder thaneaseOutCubic, 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 reproduceObjectuveLogo.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 firstrequestAnimationFramecallback 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 → appThe 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 ObjectuveLogo → ObjectuveLogoAchieved → ObjectuveLogoJourney, 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 fromObjectuveLogo.vueand recomputed on every animation frame — not a fixed-shape element animated by CSS. SeeObjectuveLogo.vue'spolarToXY/buildArcPath/buildTaperPath/easeOutQuintfor the actual geometry, and the inline<script>inindex.htmlfor its hand-port (index.htmlcannot import the component — it paints before/src/main.tsloads). The trap below (no elementtransform: rotate()on the arc, because#objectuve-gradientisuserSpaceOnUse) still holds and is more load-bearing now, since per-framedrecomputation 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):
| Element | Construction | Why |
|---|---|---|
| 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 animation | stroke-dasharray: 158.83, stroke-dashoffset 158.83 → 0 | The 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 centre | The 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 23px | Unchanged geometry; only the pop is new. Still current — the dot is untouched by OBJ-2774. |
| Track | 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:
| Construction | Pixels differing from the shipped mark |
|---|---|
<circle> + transform="rotate(-90 50 50)" | 20.3% — gradient rotated 90° |
<path> starting at 12 o'clock, no transform | 1.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 CSSanimationproperty on.splash-arc/.splash-taperto fall back to a base value. Reduced motion for the arc/taper is now handled by the inline<script>itself: it checksmatchMedia('(prefers-reduced-motion: reduce)')before starting itsrequestAnimationFrameloop 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:
.splash-dotneedsanimation-fill-mode: backwards. Its animation is delayed 792ms; withoutbackwardsthe dot sits at its base (full size, full opacity) for the first 792ms and then snaps toscale(0)to pop. Withbackwardsit 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.
| Surface | Literal (inline, inherited) | Canonical token it expresses |
|---|---|---|
| Mark gradient | #177bbb → #05cbfc | bg-gradient-primary (--primary hsl(203 78% 41%)) |
| Taper + dot | #fcc419 | --gold hsl(43 96% 56%) |
| Surface, light | radial-gradient(circle at 50% 45%, #ecf3f8 0%, #f8fafc 60%) | --background hsl(210 40% 98%) |
| Surface, dark | radial-gradient(circle at 50% 45%, #0d1f33 0%, #0b111e 60%) | dark --background |
html, body | #f8fafc light / #0b111e dark | as above — asserted by launch-splash.spec.ts, do not touch |
| Rest track | no new color — opacity: 0.16 light / 0.18 dark on the above | new 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
easeOutQuintonrequestAnimationFrame, notcubic-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
requestAnimationFramecallback paints. The arc/taperdis then recomputed every frame pereaseOutQuint(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, matchingObjectuveLogo.vue'sanimate="reveal"exactly. - Dot landing (792 → 1132ms): gold dot pops
scale(0) → 1.28 → 1on 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'scatch, which overwrites#appwith thedata-testid="bootstrap-error"card. The interstitial is replaced either way. - Success / exit:
app.mount('#app')replaces#app'sinnerHTML. 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
matchMediabefore 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 CSSanimation: nonefallback for the arc/taper. The dot and pulse (still CSS-driven) keepanimation: 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.vueno longer implements this state catalog itself — it delegates entirely to<ObjectuveLogo variant="icon" theme="light">, passinganimate="reveal"whenautoplayis true andanimate="none"otherwise. Reduced motion is not re-decided in Journey;ObjectuveLogo's ownstartAnimationForMode()already routes arevealrequest to rest whenprefers-reduced-motionis set, so Journey inherits that guard, plusObjectuveLogo's stall watchdog andvisibilitychangefail-safe, for free.
autoplay: false: passesanimate="none"toObjectuveLogo, which renders rest state, no animation.- Storybook
ReducedMotionstory: overridesmatchMediabefore mount;ObjectuveLogopicks it up internally and renders rest.
Microcopy
One string on the surface, unchanged.
| Surface | String | Tone check |
|---|---|---|
| Screen-reader label | Loading Objectuve | brief ✅, factual ✅, asserted by launch-splash.spec.ts — verbatim, do not reword |
Storybook docs strings (new copy):
| Surface | String | Tone 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-onlytext carrying the whole accessible name. All four are asserted bylaunch-splash.spec.ts— preserve verbatim. The<g>/<circle>elements added inside the SVG inheritaria-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. Thehtml, bodyand surface values are unchanged from a shipped, already-verified state. The.sr-onlytext 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
| Story | What it shows |
|---|---|
Default | Autoplaying sweep at size: 200 |
Dark | Same 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. |
SizeComparison | 96 / 128 / 200 side by side — the two real interstitial sizes plus a Storybook-legible one |
ReducedMotion | matchMedia 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
| From | Trigger | To | Transition + duration | Feedback | Reduced-motion fallback | Back-nav |
|---|---|---|---|---|---|---|
| Native splash (finished mark) | WebView ready | Interstitial frame 0 | Hard 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). | none | Reduced motion: script paints the completed mark directly on the first frame instead of starting the rAF loop | n/a (pre-app) |
| Interstitial frame 0 | — (on paint) | Interstitial rest | Arc 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, unchanged | none — no toast, no haptic; there is no user action to confirm | script skips the rAF loop and paints the completed mark; dot keeps animation: none → completed mark | n/a |
| Interstitial rest | 1132ms elapsed | Ambient pulse | launch-splash-pulse 2.2s ease-in-out infinite, delayed 1132ms | none | animation: none → static mark | n/a |
| Interstitial (any point) | app.mount('#app') | First app screen | Hard cut — innerHTML replacement. No exit animation: one would require JS and would delay interactive paint, which is the entire reason this surface exists. | none | identical | n/a |
| Interstitial (any point) | bootstrap() throws | data-testid="bootstrap-error" card | Hard cut — same innerHTML path | error card owns its own copy | identical | n/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
drecomputation and no-track items.
- [ ] Renders at 375px width without horizontal scroll
- [ ]
index.htmlgains zero new network requests before first paint. (No longer "zero JS" — a small inline<script>now hand-portsObjectuveLogo.vue's geometry/timing, since it can't import the component before/src/main.tsloads;docs/development/launch-interstitial-pattern.mddocuments this exception.) - [ ] Both
index.htmlandObjectuveLogo.vuecarry the trueeaseOutQuintexpression (1 - Math.pow(1-t, 5)),REVEAL_DURATION = 900, andREST_SWEEP = 270— the drift test inObjectuveLogoJourney.spec.tsfails if they disagree. (No longer acubic-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 asObjectuveLogo's reveal - [ ] Arc and taper
dare recomputed every animation frame (not CSS-animated); underprefers-reduced-motion: reducethe 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-dotcarriesanimation-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 — nevertransform: rotate()on the arc (rotates theuserSpaceOnUsegradient) - [ ] 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", theLoading Objectuvesr-only text, and bothhtml, bodybackground values preserved verbatim - [ ]
launch-splash.spec.tsandObjectuveLogoJourney.spec.tspass — the reduced-motion block asserts the contract for the CSS-driven elements (svg pulse, dot) and the script-drivenmatchMediacheck for the arc/taper - [ ]
tests/smoke-playwright/specs/platform/01-healthy-boot.spec.tspasses untouched - [ ] Verified in light and dark, and on a real cold launch on both platforms
- [ ] Storybook:
UI/ObjectuveLogoJourneyandUI/ObjectuveLogoAchievedboth build;Achievedrenders 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.
| Gate | Finding | Disposition |
|---|---|---|
| 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 |
| Vocabulary | references/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.vuerenders a completion celebration on the first onboarding slide (D3). Wrong beat; the newJourneyis right. One-line follow-up.admin_dashboard/src/App.vue:46uses the celebration as the ClerkLoading spinner (D4). Wrong beat; the newJourneyis 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.