Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13,329 changes: 8,886 additions & 4,443 deletions apps/shared/copy/messageContexts.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/shared/copy/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1754,6 +1754,10 @@ const messages = {
github_stars: 'GitHub Stars',
npm_monthly: 'npm monthly',
oss_contributors: 'OSS contributors',
ota_launch_illustration_aria_label: 'Animation: the app stays open while a signed bundle downloads in the background, then applies on the next launch',
ota_launch_illustration_sr_description:
'The app remains usable while Capgo delivers a signed bundle in the background. On the next launch, the same app runs the updated version.',
ota_launch_status_updating: 'Updating in background...',
Comment thread
TorichanCapgo marked this conversation as resolved.
Comment thread
TorichanCapgo marked this conversation as resolved.
give_pr_a_descriptive_title: 'Give the PR a descriptive title.',
global_infra_badge_instant: 'INSTANT',
global_infra_latency_ultra_low: 'Ultra-low latency worldwide',
Expand Down
14,020 changes: 9,320 additions & 4,700 deletions apps/shared/copy/translationContextByText.ts

Large diffs are not rendered by default.

146 changes: 2 additions & 144 deletions apps/web/src/components/HowItWorks.astro

Large diffs are not rendered by default.

187 changes: 187 additions & 0 deletions apps/web/src/components/landing/OtaLaunchIllustration.astro

Large diffs are not rendered by default.

220 changes: 220 additions & 0 deletions apps/web/src/components/landing/ota-launch-animation.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import gsap from 'gsap'

const REDUCED_MOTION = window.matchMedia('(prefers-reduced-motion: reduce)').matches

const BEAT_A_HOLD = 0.7
const BEAT_B_DOWNLOAD = 2.0
const BEAT_C_RELAUNCH = 1.4
Comment thread
cursor[bot] marked this conversation as resolved.
const PROGRESS_BAR_WIDTH = 141

// Beat C offsets/durations are fractions of BEAT_C_RELAUNCH so the storyboard stays in sync.
const BEAT_C_EXIT = BEAT_C_RELAUNCH * 0.343
const BEAT_C_DOWNLOAD_HIDE = BEAT_C_RELAUNCH * 0.143
const BEAT_C_BLANK_IN_AT = BEAT_C_RELAUNCH * 0.229
const BEAT_C_BLANK_IN = BEAT_C_RELAUNCH * 0.2
const BEAT_C_BUNDLE_ABSORB_AT = BEAT_C_RELAUNCH * 0.271
const BEAT_C_BUNDLE_ABSORB = BEAT_C_RELAUNCH * 0.3
const BEAT_C_BLANK_OUT_AT = BEAT_C_RELAUNCH * 0.586
const BEAT_C_BLANK_OUT = BEAT_C_RELAUNCH * 0.229
const BEAT_C_RETURN_AT_FRACTION = 0.643
const BEAT_C_RETURN_AT = BEAT_C_RELAUNCH * BEAT_C_RETURN_AT_FRACTION
// Return duration fills the remainder of beat C so the relaunch lands exactly at BEAT_C_RELAUNCH.
const BEAT_C_RETURN = BEAT_C_RELAUNCH * (1 - BEAT_C_RETURN_AT_FRACTION)
const BEAT_C_RINGS_AT = BEAT_C_RELAUNCH * 0.679
const BEAT_C_RINGS = BEAT_C_RELAUNCH * 0.25

function queryParts(root: HTMLElement) {
return {
chrome: root.querySelectorAll('[data-ota-chrome]'),
appUi: root.querySelector('[data-ota-app-ui]'),
staged: root.querySelector('[data-ota-staged-bundle]'),
status: root.querySelector('[data-ota-status]'),
progress: root.querySelector('[data-ota-progress]'),
progressFill: root.querySelector('[data-ota-progress-fill]'),
launchBlank: root.querySelector('[data-ota-launch-blank]'),
liveStatus: root.querySelector('[data-ota-live-status]'),
stack: root.querySelector('[data-ota-bundle-stack]'),
rings: root.querySelector('[data-ota-rings]'),
}
}

function hideDownloadUi(status: Element | null, progress: Element | null, progressFill: Element | null, liveStatus: Element | null) {
gsap.set([status, progress], { opacity: 0 })
gsap.set(progressFill, { attr: { width: 0 } })
if (liveStatus) liveStatus.textContent = ''
}

/** Show the completed OTA update state without animation (reduced-motion path). */
function setFinalState(root: HTMLElement) {
const { chrome, staged, status, progress, progressFill, launchBlank, stack, rings, appUi, liveStatus } = queryParts(root)

staged?.setAttribute('opacity', '0')
launchBlank?.setAttribute('opacity', '0')
stack?.setAttribute('opacity', '1')
rings?.setAttribute('opacity', '1')
chrome.forEach((el) => el.setAttribute('opacity', '1'))

hideDownloadUi(status, progress, progressFill, liveStatus)

for (const el of [staged, progress, launchBlank, stack, rings, status, appUi, ...chrome]) {
if (el instanceof SVGElement) {
el.style.transform = ''
gsap.set(el, { clearProps: 'opacity,transform' })
}
}

hideDownloadUi(status, progress, progressFill, liveStatus)
}

/**
* Storyboard (~4.1s):
* A — App open and usable.
* B — Download progress fills while the running UI stays up; bundle stages behind.
* C — App exits, brief launch blank, app returns with update applied.
*/
function buildTimeline(root: HTMLElement) {
const { chrome, appUi, staged, status, progress, progressFill, launchBlank, liveStatus, stack, rings } = queryParts(root)
const statusText = root.dataset.otaStatusText ?? ''

if (!appUi || !staged || !status || !progress || !progressFill || !launchBlank || !stack || !rings || chrome.length === 0) {
return null
}

gsap.set(chrome, { opacity: 1 })
gsap.set(appUi, { opacity: 1, y: 0 })
gsap.set(stack, { opacity: 1, y: 0 })
gsap.set(rings, { opacity: 0.72 })
hideDownloadUi(status, progress, progressFill, liveStatus)
gsap.set(launchBlank, { opacity: 0 })
gsap.set(staged, { opacity: 0, y: 28 })
if (liveStatus) liveStatus.textContent = ''

const timeline = gsap.timeline({ paused: true, defaults: { ease: 'power2.inOut' } })
const beatBStart = BEAT_A_HOLD
const beatCStart = beatBStart + BEAT_B_DOWNLOAD

timeline
.addLabel('beatA', 0)
.to({}, { duration: BEAT_A_HOLD })
.addLabel('beatB', beatBStart)
.to(progress, { opacity: 1, duration: 0.25 }, 'beatB')
.to(
status,
{
opacity: 1,
duration: 0.35,
onStart: () => {
if (liveStatus && statusText) liveStatus.textContent = statusText
},
},
'beatB+=0.15',
)
.to(
staged,
{
y: 0,
opacity: 0.58,
duration: 0.85,
ease: 'power2.out',
},
'beatB+=0.2',
)
.to(
progressFill,
{
attr: { width: PROGRESS_BAR_WIDTH },
duration: 1.55,
ease: 'none',
},
'beatB+=0.25',
)
.to(rings, { opacity: 1, duration: 0.6 }, 'beatB+=0.35')
.to({}, { duration: 0.25 }, 'beatB+=1.75')
.addLabel('beatC', beatCStart)
.to(
appUi,
{
y: 64,
opacity: 0,
duration: BEAT_C_EXIT,
ease: 'power2.in',
},
'beatC',
)
.to(
[status, progress],
{
opacity: 0,
duration: BEAT_C_DOWNLOAD_HIDE,
onComplete: () => hideDownloadUi(status, progress, progressFill, liveStatus),
},
'beatC',
)
.to(launchBlank, { opacity: 1, duration: BEAT_C_BLANK_IN }, `beatC+=${BEAT_C_BLANK_IN_AT}`)
.to(
staged,
{
y: -34,
opacity: 0,
duration: BEAT_C_BUNDLE_ABSORB,
ease: 'power2.in',
},
`beatC+=${BEAT_C_BUNDLE_ABSORB_AT}`,
)
.to(launchBlank, { opacity: 0, duration: BEAT_C_BLANK_OUT }, `beatC+=${BEAT_C_BLANK_OUT_AT}`)
.fromTo(
appUi,
{ y: -40, opacity: 0 },
{
y: 0,
opacity: 1,
duration: BEAT_C_RETURN,
ease: 'power2.out',
onComplete: () => hideDownloadUi(status, progress, progressFill, liveStatus),
},
`beatC+=${BEAT_C_RETURN_AT}`,
)
.to(rings, { opacity: 0.72, duration: BEAT_C_RINGS }, `beatC+=${BEAT_C_RINGS_AT}`)

return timeline
}

/** Initialize viewport-triggered OTA launch animation (plays once). */
export function setupOtaLaunchAnimations() {
document.querySelectorAll<HTMLElement>('[data-ota-launch]').forEach((root) => {
if (root.dataset.otaReady === 'true') return
root.dataset.otaReady = 'true'

if (REDUCED_MOTION) {
setFinalState(root)
return
}

const timeline = buildTimeline(root)
if (!timeline) return

let hasPlayed = false

const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasPlayed) {
hasPlayed = true
timeline.play()
observer.disconnect()
}
})
},
{ threshold: 0.45 },
)

observer.observe(root)
})
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupOtaLaunchAnimations)
} else {
setupOtaLaunchAnimations()
}
// chore: bump for CodeRabbit HEAD approval
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"bowser": "^2.14.1",
"github-slugger": "^2.0.0",
"glob": "^13.0.6",
"gsap": "^3.15.0",
"marked": "^18.0.6",
"mermaid": "^11.16.0",
"node-forge": "^1.4.0",
Expand Down
Loading