Merge branch 'main' into pr/j14i/657-1

This commit is contained in:
Bryan Galdámez 2025-06-09 17:48:15 -06:00
commit dfe652d8d8
No known key found for this signature in database
8 changed files with 319 additions and 439 deletions

View file

@ -4,6 +4,7 @@
"files": ["**/*.{ts,js,astro,html,css,json,yml,yaml,md}"], "files": ["**/*.{ts,js,astro,html,css,json,yml,yaml,md}"],
"words": [ "words": [
"adam", "adam",
"animejs",
"AMOLED", "AMOLED",
"Apóyanos", "Apóyanos",
"Astronav", "Astronav",

View file

@ -38,6 +38,7 @@
"@types/react": "^19.1.6", "@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"arktype": "^2.1.20", "arktype": "^2.1.20",
"animejs": "^4.0.2",
"astro": "5.7.10", "astro": "5.7.10",
"astro-navbar": "2.3.7", "astro-navbar": "2.3.7",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
@ -46,7 +47,6 @@
"free-astro-components": "1.2.0", "free-astro-components": "1.2.0",
"jiti": "^2.4.2", "jiti": "^2.4.2",
"lefthook": "^1.11.13", "lefthook": "^1.11.13",
"motion": "^12.15.0",
"postcss": "8.5.1", "postcss": "8.5.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@ -72,6 +72,7 @@
"@playwright/test": "1.52.0", "@playwright/test": "1.52.0",
"@testing-library/jest-dom": "6.6.3", "@testing-library/jest-dom": "6.6.3",
"@testing-library/user-event": "14.6.1", "@testing-library/user-event": "14.6.1",
"@types/animejs": "^3.1.13",
"@types/eslint-plugin-jsx-a11y": "6.10.0", "@types/eslint-plugin-jsx-a11y": "6.10.0",
"@types/jsdom": "21.1.7", "@types/jsdom": "21.1.7",
"@types/node": "22.15.18", "@types/node": "22.15.18",

76
pnpm-lock.yaml generated
View file

@ -44,6 +44,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.1.5 specifier: ^19.1.5
version: 19.1.6(@types/react@19.1.6) version: 19.1.6(@types/react@19.1.6)
animejs:
specifier: ^4.0.2
version: 4.0.2
arktype: arktype:
specifier: ^2.1.20 specifier: ^2.1.20
version: 2.1.20 version: 2.1.20
@ -71,9 +74,6 @@ importers:
lefthook: lefthook:
specifier: ^1.11.13 specifier: ^1.11.13
version: 1.11.13 version: 1.11.13
motion:
specifier: ^12.15.0
version: 12.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
postcss: postcss:
specifier: 8.5.1 specifier: 8.5.1
version: 8.5.1 version: 8.5.1
@ -144,6 +144,9 @@ importers:
'@testing-library/user-event': '@testing-library/user-event':
specifier: 14.6.1 specifier: 14.6.1
version: 14.6.1(@testing-library/dom@10.4.0) version: 14.6.1(@testing-library/dom@10.4.0)
'@types/animejs':
specifier: ^3.1.13
version: 3.1.13
'@types/eslint-plugin-jsx-a11y': '@types/eslint-plugin-jsx-a11y':
specifier: 6.10.0 specifier: 6.10.0
version: 6.10.0 version: 6.10.0
@ -1514,6 +1517,9 @@ packages:
'@tybys/wasm-util@0.9.0': '@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
'@types/animejs@3.1.13':
resolution: {integrity: sha512-yWg9l1z7CAv/TKpty4/vupEh24jDGUZXv4r26StRkpUPQm04ztJaftgpto8vwdFs8SiTq6XfaPKCSI+wjzNMvQ==}
'@types/aria-query@5.0.4': '@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@ -1850,6 +1856,9 @@ packages:
ajv@8.17.1: ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
animejs@4.0.2:
resolution: {integrity: sha512-f0L/kSya2RF23iMSF/VO01pMmLwlAFoiQeNAvBXhEyLzIPd2/QTBRatwGUqkVCC6seaAJYzAkGir55N4SL+h3A==}
ansi-align@3.0.1: ansi-align@3.0.1:
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
@ -2774,20 +2783,6 @@ packages:
fraction.js@4.3.7: fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
framer-motion@12.16.0:
resolution: {integrity: sha512-xryrmD4jSBQrS2IkMdcTmiS4aSKckbS7kLDCuhUn9110SQKG1w3zlq1RTqCblewg+ZYe+m3sdtzQA6cRwo5g8Q==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
free-astro-components@1.2.0: free-astro-components@1.2.0:
resolution: {integrity: sha512-bsT9dWsNlRGDNjqcoIlz6w8NcSCgOpx6oxiwZgYwq9RVbi3JqUImPc6c4Kico2wRJSIXc7HHyr71QgmgXv7nfg==} resolution: {integrity: sha512-bsT9dWsNlRGDNjqcoIlz6w8NcSCgOpx6oxiwZgYwq9RVbi3JqUImPc6c4Kico2wRJSIXc7HHyr71QgmgXv7nfg==}
@ -3602,26 +3597,6 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
motion-dom@12.16.0:
resolution: {integrity: sha512-Z2nGwWrrdH4egLEtgYMCEN4V2qQt1qxlKy/uV7w691ztyA41Q5Rbn0KNGbsNVDZr9E8PD2IOQ3hSccRnB6xWzw==}
motion-utils@12.12.1:
resolution: {integrity: sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==}
motion@12.16.0:
resolution: {integrity: sha512-P3HA83fnPMEGBLfKdD5vDdjH1Aa3wM3jT3+HX3fCVpy/4/lJiqvABajLgZenBu+rzkFzmeaPkvT7ouf9Tq5tVQ==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
mrmime@2.0.1: mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -6353,6 +6328,8 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@types/animejs@3.1.13': {}
'@types/aria-query@5.0.4': {} '@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
@ -6755,6 +6732,8 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
animejs@4.0.2: {}
ansi-align@3.0.1: ansi-align@3.0.1:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
@ -8008,15 +7987,6 @@ snapshots:
fraction.js@4.3.7: {} fraction.js@4.3.7: {}
framer-motion@12.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
motion-dom: 12.16.0
motion-utils: 12.12.1
tslib: 2.8.1
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
free-astro-components@1.2.0(@types/node@22.15.18)(jiti@2.4.2)(prettier-plugin-astro@0.14.1)(prettier@3.5.3)(rollup@4.42.0)(yaml@2.8.0): free-astro-components@1.2.0(@types/node@22.15.18)(jiti@2.4.2)(prettier-plugin-astro@0.14.1)(prettier@3.5.3)(rollup@4.42.0)(yaml@2.8.0):
dependencies: dependencies:
'@astrojs/check': 0.9.4(prettier-plugin-astro@0.14.1)(prettier@3.5.3)(typescript@5.6.3) '@astrojs/check': 0.9.4(prettier-plugin-astro@0.14.1)(prettier@3.5.3)(typescript@5.6.3)
@ -9096,20 +9066,6 @@ snapshots:
minipass@7.1.2: {} minipass@7.1.2: {}
motion-dom@12.16.0:
dependencies:
motion-utils: 12.12.1
motion-utils@12.12.1: {}
motion@12.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
framer-motion: 12.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
tslib: 2.8.1
optionalDependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
mrmime@2.0.1: {} mrmime@2.0.1: {}
ms@2.1.3: {} ms@2.1.3: {}

View file

@ -1,10 +1,7 @@
--- ---
import Image from 'astro/components/Image.astro' import Image from 'astro/components/Image.astro'
import { motion } from 'motion/react'
import { getTitleAnimation } from '~/animations'
import ComImage from '~/assets/ComImage.png' import ComImage from '~/assets/ComImage.png'
import Button from '~/components/Button.astro' import Button from '~/components/Button.astro'
import Description from '~/components/Description.astro'
import CheckIcon from '~/icons/CheckIcon.astro' import CheckIcon from '~/icons/CheckIcon.astro'
import GitHubIcon from '~/icons/GitHubIcon.astro' import GitHubIcon from '~/icons/GitHubIcon.astro'
import { getLocale, getUI } from '~/utils/i18n' import { getLocale, getUI } from '~/utils/i18n'
@ -19,50 +16,85 @@ const {
--- ---
<section <section
id="Community" id="community"
class="relative flex w-full flex-col items-center gap-6 py-12 text-start md:text-center lg:py-36" class="relative flex w-full flex-col items-center gap-6 py-12 text-start md:text-center lg:py-36"
> >
<Description class="mb-2 text-4xl font-bold sm:text-6xl"> <h2 class="mb-2 text-4xl font-bold sm:text-6xl">
{ {
community.title.map((title, index) => community.title.map(title =>
title !== '\n' ? ( title !== '\n' ? (
<motion.span client:load {...getTitleAnimation(0.2 + index * 0.2)}> <span style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)">
{title} {title}
</motion.span> </span>
) : ( ) : (
<br class="hidden md:block" /> <br class="hidden md:block" />
) )
) )
} }
</Description> </h2>
<motion.p client:load {...getTitleAnimation(0.6)} className="lg:w-1/2 lg:px-0"> <p
{community.description} class="text-base lg:w-1/2 lg:px-0"
</motion.p> style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
>
<span class="opacity-80">{community.description}</span>
</p>
<div class="flex w-full flex-wrap gap-3 sm:gap-10 md:justify-center"> <div class="flex w-full flex-wrap gap-3 sm:gap-10 md:justify-center">
<motion.span client:load {...getTitleAnimation(0.8)}> <div
class="community__button"
style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
>
<Button class:list={['px-4']} href="https://github.com/zen-browser"> <Button class:list={['px-4']} href="https://github.com/zen-browser">
<GitHubIcon class="size-4" /> <GitHubIcon class="size-4" />
<span>{community.lists.freeAndOpenSource.title}</span> <span>{community.lists.freeAndOpenSource.title}</span>
</Button> </Button>
</motion.span> </div>
<motion.div client:load {...getTitleAnimation(1)} className="flex items-center gap-4"> <div
class="community__button flex items-center gap-4"
style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
>
<CheckIcon class="size-4" /> <CheckIcon class="size-4" />
<span>{community.lists.simpleYetPowerful.title}</span> <span>{community.lists.simpleYetPowerful.title}</span>
</motion.div> </div>
<motion.div client:load {...getTitleAnimation(1.2)} className="flex items-center gap-4"> <div
class="community__button flex items-center gap-4"
style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
>
<CheckIcon class="size-4" /> <CheckIcon class="size-4" />
<span>{community.lists.privateAndAlwaysUpToDate.title}</span> <span>{community.lists.privateAndAlwaysUpToDate.title}</span>
</motion.div>
</div> </div>
<motion.span </div>
className="flex max-w-full lg:max-w-none lg:flex-none" <span class="flex max-w-full lg:max-w-none lg:flex-none">
client:load
{...getTitleAnimation(1.4)}
>
<Image <Image
src={ComImage} src={ComImage}
alt={community.images.community.alt} alt={community.images.community.alt}
class="rounded-3xl shadow-md lg:mx-auto dark:opacity-80" class="rounded-3xl shadow-md lg:mx-auto dark:opacity-80"
style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
/> />
</motion.span> </span>
</section> </section>
<script>
import { animate, onScroll, stagger } from 'animejs'
function initAnimations() {
const debug = false
const elements = document.querySelectorAll(
'#community h2 span, #community p, #community .community__button, #community img'
)
animate(elements, {
opacity: { from: 0.001, to: 1 },
translateY: { from: 20, to: 0 },
filter: { from: 'blur(4px)', to: 'blur(0px)' },
duration: 300,
delay: stagger(150),
ease: 'cubicBezier(0.25, 0.1, 0.25, 1)',
autoplay: onScroll({
target: '#community',
debug,
}),
})
}
initAnimations()
</script>

View file

@ -1,9 +1,5 @@
--- ---
import { motion } from 'motion/react'
import { getTitleAnimation } from '~/animations'
import { getLocale, getUI } from '~/utils/i18n' import { getLocale, getUI } from '~/utils/i18n'
import Description from './Description.astro'
import Video from './Video.astro' import Video from './Video.astro'
const locale = getLocale(Astro) const locale = getLocale(Astro)
@ -20,290 +16,137 @@ interface Props {
const { titles } = Astro.props const { titles } = Astro.props
const descriptions = Object.values(features.featureTabs).map(tab => tab.description) const featureList = [
{
key: 'workspaces',
title: features.featureTabs.workspaces.title,
description: features.featureTabs.workspaces.description,
video: 'workspaces',
},
{
key: 'compact-mode',
title: features.featureTabs.compactMode.title,
description: features.featureTabs.compactMode.description,
video: 'compact-mode',
},
{
key: 'glance',
title: features.featureTabs.glance.title,
description: features.featureTabs.glance.description,
video: 'glance',
},
{
key: 'split-view',
title: features.featureTabs.splitView.title,
description: features.featureTabs.splitView.description,
video: 'split-views',
},
]
--- ---
<section id="Features" class="relative flex w-full flex-col py-12 text-start lg:py-36"> <section
<Description class="mb-2 text-4xl font-bold sm:text-6xl"> id="features"
class="relative flex w-full flex-col items-center gap-8 py-8 text-center sm:gap-12 sm:py-12 xl:gap-24 xl:py-36"
>
<!-- Section Header -->
<div class="flex w-full flex-col items-center gap-4 sm:gap-6">
<h2 class="text-3xl sm:text-4xl md:text-6xl">
{ {
(titles || features.titles).map((title, index) => (titles || features.titles).map(title => {
title !== '\n' ? ( switch (title) {
<motion.span client:load {...getTitleAnimation(0.2 + index * 0.2)}> case '\n':
return <br class="hidden md:block" />
default:
return (
<b
class="font-bold"
style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
>
{title} {title}
</motion.span> </b>
) : (
<br class="hidden md:block" />
)
) )
} }
</Description> })
<motion.p client:load {...getTitleAnimation(0.6)} className="lg:w-1/2"> }
{features.description} </h2>
</motion.p> <p
<div class="mt-6 flex flex-col gap-6 lg:flex-row lg:justify-between lg:gap-2"> class="max-w-4xl text-base sm:text-lg"
<div class="flex w-full flex-col lg:w-1/3"> style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
<!-- Mobile tabs -->
<div class="flex gap-2 overflow-x-auto overflow-y-clip lg:hidden">
<motion.button
client:load
{...getTitleAnimation()}
className="feature-tab whitespace-nowrap"
data-active="true"
> >
{features.featureTabs.workspaces.title} <span class="opacity-80">{features.description}</span>
</motion.button> </p>
<motion.button
client:load
{...getTitleAnimation(0.2)}
className="feature-tab whitespace-nowrap"
>
{features.featureTabs.compactMode.title}
</motion.button>
<motion.button
client:load
{...getTitleAnimation(0.4)}
className="feature-tab whitespace-nowrap"
>
{features.featureTabs.glance.title}
</motion.button>
<motion.button
client:load
{...getTitleAnimation(0.6)}
className="feature-tab whitespace-nowrap"
>
{features.featureTabs.splitView.title}
</motion.button>
</div> </div>
<!-- Desktop features list --> <!-- Features Layout -->
<div id="features-list" class="hidden lg:flex lg:flex-col lg:gap-3"> <div id="showcase" class="flex w-full flex-col gap-12 sm:gap-16 xl:gap-36">
<motion.div client:load {...getTitleAnimation(0.8)} className="feature" data-active="true"> {
<Description class="text-2xl font-bold"> featureList.map((feature, index) => (
{features.featureTabs.workspaces.title} <div class="flex w-full flex-col items-center gap-6 xl:gap-12">
</Description> <div class="flex flex-col items-center gap-4">
<Description> <h3 class="text-3xl font-bold opacity-0 blur-sm sm:text-4xl xl:text-5xl">
{features.featureTabs.workspaces.description} {feature.title}
</Description> </h3>
</motion.div> <p
<motion.div client:load {...getTitleAnimation(1)} className="feature"> class="max-w-3xl text-center text-base leading-relaxed sm:text-lg"
<Description class="text-2xl font-bold"> style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
{features.featureTabs.compactMode.title} >
</Description> <span class="opacity-80">{feature.description}</span>
<Description> </p>
{features.featureTabs.compactMode.description}
</Description>
</motion.div>
<motion.div client:load {...getTitleAnimation(1.2)} className="feature">
<Description class="text-2xl font-bold">
{features.featureTabs.glance.title}
</Description>
<Description>
{features.featureTabs.glance.description}
</Description>
</motion.div>
<motion.div client:load {...getTitleAnimation(1.4)} className="feature">
<Description class="text-2xl font-bold">
{features.featureTabs.splitView.title}
</Description>
<Description>
{features.featureTabs.splitView.description}
</Description>
</motion.div>
</div> </div>
<!-- Mobile description -->
<div class="feature-description mt-4 lg:hidden" data-descriptions={descriptions.join('|||')}>
</div>
</div>
<div class="sticky top-6 h-fit w-full lg:w-3/5">
<div class="relative w-full">
<div class="video-stack relative h-full w-full">
<Video <Video
class="w-full rounded-xl object-cover shadow-lg"
style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
name={feature.video}
autoplay autoplay
loop loop
muted muted
playsinline playsinline
preload="none" preload="none"
class="feature-video"
name="workspaces"
/>
<Video
autoplay
loop
muted
playsinline
preload="none"
class="feature-video"
name="compact-mode"
/>
<Video
autoplay
loop
muted
playsinline
preload="none"
class="feature-video"
name="glance"
/>
<Video
autoplay
loop
muted
playsinline
preload="none"
class="feature-video"
name="split-views"
/> />
</div> </div>
</div> ))
</div> }
</div> </div>
</section> </section>
<script> <script>
const features = document.querySelectorAll('.feature, .feature-tab') as NodeListOf<HTMLElement> import { animate, onScroll, stagger } from 'animejs'
// Set initial description function initAnimations() {
const descriptionEl = document.querySelector('.feature-description') as HTMLDivElement const debug = false
const descriptions = descriptionEl?.dataset.descriptions?.split('|||')
if (descriptionEl && descriptions) {
descriptionEl.textContent = descriptions[0]
}
function changeToFeature({ target }: MouseEvent | { target: HTMLElement }) { const elements = document.querySelectorAll('#features h2 b, #features > div > p')
let targetEl: HTMLElement | null = target as HTMLElement const showcase = document.getElementById('showcase') as HTMLElement
if (target instanceof HTMLElement) { animate(elements, {
targetEl = target.closest('.feature, .feature-tab') opacity: { from: 0.001, to: 1 },
} translateY: { from: 20, to: 0 },
filter: { from: 'blur(4px)', to: 'blur(0px)' },
duration: 300,
delay: stagger(150),
ease: 'cubicBezier(0.25, 0.1, 0.25, 1)',
autoplay: onScroll({
target: '#features',
debug,
}),
})
if (!targetEl) { for (const element of showcase.children) {
return const target = element.querySelectorAll('p, h3, video')
} animate(target, {
opacity: { from: 0.001, to: 1 },
const index = Array.from(features).indexOf(targetEl) % 4 translateY: { from: 20, to: 0 },
filter: { from: 'blur(4px)', to: 'blur(0px)' },
if (index === -1) { duration: 300,
return delay: stagger(150),
} ease: 'cubicBezier(0.25, 0.1, 0.25, 1)',
autoplay: onScroll({
// Update both mobile and desktop elements enter: { container: 'top+=500' },
for (let i = 0; i < features.length; i += 1) { target: element,
const f = features[i] debug,
}),
if (i % 4 === index) { })
f.setAttribute('data-active', 'true')
} else {
f.removeAttribute('data-active')
} }
} }
initAnimations()
// Update mobile description
const descriptionEl = document.querySelector('.feature-description')
if (descriptionEl && descriptions) {
descriptionEl.textContent = descriptions[index]
}
const videos = document.querySelectorAll<HTMLVideoElement>('.feature-video')
for (let i = 0; i < videos.length; i += 1) {
const vid = videos[i]
const yOffset = (i - index) * 20
const zOffset = i === index ? 0 : -100 - Math.abs(i - index) * 50
const scale = i === index ? 1 : 0.95
const rotation = (i - index) * 3
if (i === index) {
vid.setAttribute('data-active', 'true')
vid.style.opacity = '1'
vid.style.transform = `translate3d(-50%, 0, 0) scale(${scale})`
vid.style.zIndex = '10'
vid.currentTime = 0
vid.play()
} else {
vid.removeAttribute('data-active')
vid.style.transform = `translate3d(-50%, ${yOffset}px, ${zOffset}px)
rotate3d(1, 0, 0, ${rotation}deg)
scale(${scale})`
vid.style.zIndex = String(1 - Math.abs(i - index))
vid.pause()
}
}
}
for (const feature of features) {
feature.addEventListener('click', changeToFeature)
}
changeToFeature({ target: features[0] })
</script> </script>
<style>
.feature {
@apply w-full cursor-pointer select-none rounded-lg p-4 opacity-0 hover:bg-subtle;
transition: background 0.2s ease-in-out;
&[data-active='true'] {
@apply bg-subtle;
}
}
.feature-tab {
@apply rounded-lg px-4 py-2 text-lg font-medium opacity-0 hover:bg-subtle;
transition: background 0.2s ease-in-out;
&[data-active='true'] {
@apply bg-subtle;
}
}
.feature-description {
@apply px-4 text-sm;
}
.video-stack {
@apply aspect-video;
perspective: 2000px;
transform-style: preserve-3d;
}
.feature-video {
@apply left-1/2 hidden transform rounded-3xl shadow-md lg:absolute lg:mx-auto lg:block lg:w-full dark:opacity-80;
max-width: 800px;
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: absolute;
top: 0;
transform-origin: top center;
backface-visibility: hidden;
will-change: transform, opacity;
transform: translate3d(-50%, 0, -100px) scale(0.95);
}
/* Don't animate translation on small screens */
@media (max-width: 1024px) {
.feature-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
transform: none !important;
display: none;
object-fit: cover;
&[data-active='true'] {
display: block;
opacity: 1;
}
}
.video-stack {
@apply overflow-hidden rounded-xl;
position: relative;
width: 100%;
aspect-ratio: 16/9;
}
}
</style>

View file

@ -1,24 +1,11 @@
--- ---
import { motion } from 'motion/react'
import { getTitleAnimation } from '~/animations'
import Button from '~/components/Button.astro' import Button from '~/components/Button.astro'
import Description from '~/components/Description.astro'
import Title from '~/components/Title.astro' import Title from '~/components/Title.astro'
import ArrowRightIcon from '~/icons/ArrowRightIcon.astro' import ArrowRightIcon from '~/icons/ArrowRightIcon.astro'
import { getLocale, getPath, getUI } from '~/utils/i18n' import { getLocale, getPath, getUI } from '~/utils/i18n'
import SocialMediaStrip from './SocialMediaStrip.astro' import SocialMediaStrip from './SocialMediaStrip.astro'
import Video from './Video.astro' import Video from './Video.astro'
let titleAnimationCounter = 0
function getNewAnimationDelay() {
titleAnimationCounter++
return titleAnimationCounter * 0.15
}
function getHeroTitleAnimation() {
return getTitleAnimation(getNewAnimationDelay())
}
const locale = getLocale(Astro) const locale = getLocale(Astro)
const getLocalePath = getPath(locale) const getLocalePath = getPath(locale)
@ -32,58 +19,54 @@ const {
<header <header
id="header" id="header"
class="flex w-full flex-col items-center gap-[20%] py-32 text-center lg:gap-[25%]" class="flex w-full flex-col items-center gap-6 py-16 text-center lg:gap-12 lg:py-32"
> >
<div class="flex h-full flex-col items-center justify-center"> <div class="flex h-full flex-col items-center justify-center gap-6 md:gap-8">
<Title class="relative px-12 text-center font-normal md:text-7xl lg:px-0 lg:text-9xl"> <div class="flex flex-col items-center justify-center gap-4 md:gap-8">
<div>
<Title class="relative px-12 text-center text-5xl md:text-7xl lg:px-0 lg:text-9xl">
{ {
hero.title.map(title => hero.title.map(title =>
title.text !== '\n' ? ( title.text !== '\n' ? (
<motion.span <b
client:load class:list={['font-normal', title.highlight && 'italic text-coral']}
{...getHeroTitleAnimation()} style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
className={title.highlight ? 'italic text-coral' : ''}
> >
{title.text} {title.text}
</motion.span> </b>
) : ( ) : (
<br class="hidden md:block" /> <br class="hidden md:block" />
) )
) )
} }
</Title> </Title>
<motion.span client:load {...getHeroTitleAnimation()}> <p
<Description class="px-12 text-center lg:px-0"> class="px-12 text-center lg:px-0"
style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
>
{hero.description[0]} {hero.description[0]}
<br class="hidden sm:inline" /> <br class="hidden sm:inline" />
{hero.description[1]}</Description {hero.description[1]}
</p>
</div>
<div
class="flex w-2/3 flex-col items-center justify-center gap-3 sm:gap-6 md:w-fit md:flex-row"
> >
</motion.span> <div style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)">
<div class="mt-6 flex w-2/3 flex-col gap-3 sm:gap-6 md:w-fit md:flex-row"> <Button class="w-fit" href={getLocalePath('/download')} isPrimary>
<motion.span client:load {...getHeroTitleAnimation()}>
<Button class="w-full" href={getLocalePath('/download')} isPrimary>
{hero.buttons.beta} {hero.buttons.beta}
<ArrowRightIcon class="size-4" /> <ArrowRightIcon class="size-4" />
</Button> </Button>
</motion.span>
<motion.span client:load {...getHeroTitleAnimation()}>
<Button href={getLocalePath('/donate')}>{hero.buttons.support}</Button>
</motion.span>
</div> </div>
<motion.span <div style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)">
client:load <Button class="w-fit" href={getLocalePath('/donate')}>
{...getHeroTitleAnimation()} {hero.buttons.support}
className="mx-auto translate-y-16 !transform" </Button>
> </div>
<SocialMediaStrip /> </div>
</motion.span> </div>
<SocialMediaStrip style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)" />
</div> </div>
</header>
<motion.span
className="flex max-w-full lg:max-w-none lg:flex-none"
client:load
{...getHeroTitleAnimation()}
>
<Video <Video
name="hero-video" name="hero-video"
autoplay autoplay
@ -91,6 +74,39 @@ const {
muted muted
playsinline playsinline
preload="none" preload="none"
class="mb-24 rounded-3xl shadow-md dark:opacity-80" class="rounded-xl"
style="transform: translateY(20px); opacity: 0.001; filter: blur(4px); transform-origin: top;"
/> />
</motion.span> </header>
<script>
import { animate, onScroll, stagger } from 'animejs'
function initAnimations() {
const debug = false
const elements = document.querySelectorAll(
'#header h1 b, #header p, #header div:has(a), #header video, #header ul'
)
animate(elements, {
// @ts-expect-error - element is HTMLElement
opacity: element => {
if (element.tagName === 'UL') {
return { from: 0.001, to: 0.8 }
}
return { from: 0.001, to: 1 }
},
translateY: { from: 20, to: 0 },
filter: { from: 'blur(4px)', to: 'blur(0px)' },
duration: 300,
delay: stagger(150),
ease: 'cubicBezier(0.25, 0.1, 0.25, 1)',
autoplay: onScroll({
target: '#header',
debug,
}),
})
}
initAnimations()
</script>

View file

@ -1,5 +1,5 @@
--- ---
const { gap = 4 } = Astro.props const { class: className, ...props } = Astro.props
import { icon, library } from '@fortawesome/fontawesome-svg-core' import { icon, library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -9,6 +9,7 @@ import {
faReddit, faReddit,
faXTwitter, faXTwitter,
} from '@fortawesome/free-brands-svg-icons' } from '@fortawesome/free-brands-svg-icons'
import { cn } from '~/utils/merge'
library.add(faMastodon, faBluesky, faGithub, faXTwitter, faReddit) library.add(faMastodon, faBluesky, faGithub, faXTwitter, faReddit)
const Mastodon = icon({ prefix: 'fab', iconName: 'mastodon' }) const Mastodon = icon({ prefix: 'fab', iconName: 'mastodon' })
@ -18,7 +19,7 @@ const XTwitter = icon({ prefix: 'fab', iconName: 'x-twitter' })
const Reddit = icon({ prefix: 'fab', iconName: 'reddit' }) const Reddit = icon({ prefix: 'fab', iconName: 'reddit' })
--- ---
<ul class={`flex items-center opacity-80 gap-${gap}`}> <ul class={cn('flex items-center gap-4 opacity-80', className)} {...props}>
<li> <li>
<a <a
href="https://github.com/zen-browser" href="https://github.com/zen-browser"

View file

@ -1,7 +1,5 @@
--- ---
import { motion } from 'motion/react' import h3 from '~/components/Description.astro'
import { getTitleAnimation } from '~/animations'
import Description from '~/components/Description.astro'
import { getLocale, getUI } from '~/utils/i18n' import { getLocale, getUI } from '~/utils/i18n'
const locale = getLocale(Astro) const locale = getLocale(Astro)
@ -19,23 +17,55 @@ const {
--- ---
<section id="sponsors" class:list={['py-12', !showSponsors && 'hidden']}> <section id="sponsors" class:list={['py-12', !showSponsors && 'hidden']}>
<div class="mx-auto flex flex-col text-center"> <div class="flex flex-col items-center gap-6 text-center">
<motion.span client:load {...getTitleAnimation(0.2)}> <h3
<Description class="mb-2 text-4xl font-bold sm:text-6xl">{sponsors.title}</Description> class="text-4xl font-bold sm:text-6xl"
</motion.span> style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
<motion.span client:load {...getTitleAnimation(0.4)}> >
<Description set:html={sponsors.description} /> {sponsors.title}
</motion.span> </h3>
<div class="relative mt-8 flex items-center justify-center"> <p class="text-base" style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)">
<motion.span client:load {...getTitleAnimation(0.6)}> <span class="opacity-80" set:html={sponsors.description} />
<a href={sponsors.sponsors['tuta'].url} target="_blank" class="w-fit"> </p>
<div class="sponsors__sponsor relative mt-8 flex items-center justify-center">
<a
href={sponsors.sponsors['tuta'].url}
target="_blank"
class="w-fit"
style="transform: translateY(20px); opacity: 0.001; filter: blur(4px)"
>
<Image <Image
src={tutaLogo} src={tutaLogo}
alt={sponsors.sponsors['tuta'].name} alt={sponsors.sponsors['tuta'].name}
class="h-16 w-fit object-contain" class="h-16 w-fit object-contain"
/> />
</a> </a>
</motion.span>
</div> </div>
</div> </div>
</section> </section>
<script>
import { animate, onScroll, stagger } from 'animejs'
function initAnimations() {
const debug = false
const elements = document.querySelectorAll(
'#sponsors h3, #sponsors p, #sponsors .sponsors__sponsor a'
)
animate(elements, {
opacity: { from: 0.001, to: 1 },
translateY: { from: 20, to: 0 },
filter: { from: 'blur(4px)', to: 'blur(0px)' },
duration: 300,
delay: stagger(150),
ease: 'cubicBezier(0.25, 0.1, 0.25, 1)',
autoplay: onScroll({
target: '#sponsors',
debug,
}),
})
}
initAnimations()
</script>