Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#006662" />
<title>CAPY</title>
<meta name="description" content="All-in-one student involvement utility." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"jest": "^30.3.0",
"jest-environment-jsdom": "^30.3.0",
"lint-staged": "^16.4.0",
"prettier": "^3.8.1",
"prettier": "^3.8.3",
"ts-jest": "^29.4.6",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
Expand Down
6 changes: 6 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ body,
html,
body {
overflow: hidden;
background: var(--c-bg);
transition: background-color 200ms ease;
}

body.rail-active {
background: var(--c-surface-light);
}

body {
Expand Down
39 changes: 39 additions & 0 deletions src/lander/Lander.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,42 @@
min-height: var(--lander-panel-height);
border-radius: var(--radius-card);
}

.mobileSnapScroller {
display: none;
}

@media (max-width: 768px) {
.appRoot {
overflow: hidden;
height: 100dvh;
min-height: 100dvh;
padding: 0;
background: var(--c-bg);
}

.mobileSnapScroller {
display: block;
width: 100%;
height: 100dvh;
overflow-y: scroll;
overflow-x: hidden;
scroll-snap-type: y mandatory;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}

.mobileSnapScroller::-webkit-scrollbar {
display: none;
}

:global(.panel) {
width: 100%;
height: 100dvh;
min-height: 100dvh;
border-radius: 0;
border: none;
scroll-snap-align: start;
scroll-snap-stop: always;
}
}
53 changes: 38 additions & 15 deletions src/lander/Lander.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { TopNav } from '@/shared/components/TopNav'
import { ExitOverlay } from '@/shared/components/ExitOverlay'
Expand All @@ -12,14 +12,28 @@ import { InterfaceSection } from './sections/InterfaceSection'
import { Helmet } from 'react-helmet-async'
import styles from './Lander.module.css'

const MOBILE_QUERY = '(max-width: 768px)'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the responsive logic and shared sections into reusable hooks/components so Lander stays focused and avoids duplicated JSX and ad‑hoc control flow.

You can keep the new behavior and trim quite a bit of complexity with a few small extra abstractions.

1. Extract media-query logic into a hook

Move the MOBILE_QUERY + useState + useEffect bundle out of Lander:

// shared/hooks/useIsMobile.ts
import { useEffect, useState } from 'react'

const MOBILE_QUERY = '(max-width: 768px)'

export function useIsMobile() {
  const [isMobile, setIsMobile] = useState(
    () => typeof window !== 'undefined' && window.matchMedia(MOBILE_QUERY).matches,
  )

  useEffect(() => {
    const mq = window.matchMedia(MOBILE_QUERY)
    const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches)

    handler(mq as any) // ensure initial sync if needed
    mq.addEventListener('change', handler)
    return () => mq.removeEventListener('change', handler)
  }, [])

  return isMobile
}

Then Lander becomes simpler:

import { useRef } from 'react'
import { useIsMobile } from '@/shared/hooks/useIsMobile'

function Lander() {
  const scrollerRef = useRef<HTMLElement | null>(null)!
  const isMobile = useIsMobile()

  // ...
}

2. Simplify useHorizontalWheelScroll API

Remove the dummy disabledRef by letting the hook no-op on null:

// shared/hooks/useHorizontalWheelScroll.ts
export function useHorizontalWheelScroll(
  ref: React.RefObject<HTMLElement | null> | null,
  options: { endCutoffPx?: number; enabled?: boolean } = {},
) {
  const { enabled = true, endCutoffPx } = options

  useEffect(() => {
    if (!enabled || !ref?.current) return
    // existing logic here...
  }, [enabled, ref, endCutoffPx])
}

Usage in Lander:

const scrollerRef = useRef<HTMLElement | null>(null)!
const isMobile = useIsMobile()

useHorizontalWheelScroll(isMobile ? null : scrollerRef, { endCutoffPx: 300 })

No extra ref, clearer intent.

3. Deduplicate shared sections

You can keep mobile-only and desktop-only sections while avoiding repeated JSX:

const sharedSections = (
  <>
    <HeroSection />
    <FeaturesSection />
    <CapyRailSection />
  </>
)

function Lander() {
  const scrollerRef = useRef<HTMLElement | null>(null)!
  const isMobile = useIsMobile()

  useHorizontalWheelScroll(isMobile ? null : scrollerRef, { endCutoffPx: 300 })

  return (
    <>
      <Helmet>{/* ... */}</Helmet>
      <div className={styles.appRoot}>
        {isMobile ? (
          <main className={styles.mobileSnapScroller}>{sharedSections}</main>
        ) : (
          <>
            <TopNav />
            <main
              className={styles.horizontalScroller}
              ref={scrollerRef}
              id="scroller"
            >
              <motion.div
                className={styles.panelTrack}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                transition={{ duration: 0.6 }}
              >
                {sharedSections}
                <InterfaceSection />
                <ContactSection />
              </motion.div>
            </main>
          </>
        )}
      </div>
      <ExitOverlay />
    </>
  )
}

This keeps the mobile/desktop differences (no TopNav, InterfaceSection, ContactSection on mobile) but removes duplication and makes it easier to maintain section ordering.


/**
* The main Lander component.
* Serves as the landing page for capy, featuring scrolling sections and product details.
* Contains the Hero, Features, Interface, Contact, and Rail sections.
*/
function Lander() {
const scrollerRef = useRef<HTMLElement | null>(null)
useHorizontalWheelScroll(scrollerRef, { endCutoffPx: 300 })
const disabledRef = useRef<HTMLElement | null>(null)
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && window.matchMedia(MOBILE_QUERY).matches,
)

useEffect(() => {
const mq = window.matchMedia(MOBILE_QUERY)
const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])

useHorizontalWheelScroll(isMobile ? disabledRef : scrollerRef, { endCutoffPx: 300 })

usePageTransition()

Expand All @@ -33,22 +47,31 @@ function Lander() {
/>
</Helmet>
<div className={styles.appRoot}>
<TopNav />

<main className={styles.horizontalScroller} ref={scrollerRef} id="scroller">
<motion.div
className={styles.panelTrack}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
>
{isMobile ? (
<main className={styles.mobileSnapScroller}>
<HeroSection />
<FeaturesSection />
<InterfaceSection />
<ContactSection />
<CapyRailSection />
</motion.div>
</main>
</main>
) : (
<>
<TopNav />
<main className={styles.horizontalScroller} ref={scrollerRef} id="scroller">
<motion.div
className={styles.panelTrack}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
>
<HeroSection />
<FeaturesSection />
<InterfaceSection />
<ContactSection />
<CapyRailSection />
</motion.div>
</main>
</>
)}
</div>
<ExitOverlay />
</>
Expand Down
47 changes: 47 additions & 0 deletions src/lander/sections/CapyRailSection.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,50 @@
opacity: 0.28;
}
}

@media (max-width: 768px) {
.capyRailPanel {
width: 100%;
height: 100dvh;
border: none;
border-radius: 0;
background: var(--c-surface-light);
}

.railContent {
width: calc(100% - var(--space-32));
margin: calc(var(--space-50) + env(safe-area-inset-top)) var(--space-16)
calc(var(--space-32) + env(safe-area-inset-bottom));
height: calc(
100dvh - var(--space-50) - var(--space-32) - env(safe-area-inset-top) -
env(safe-area-inset-bottom)
);
}

.railLinks {
margin-top: var(--space-24);
}

.verticalMarkWrap {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: auto;
right: 0;
width: 160px;
height: 100%;
opacity: 0.25;
pointer-events: none;
}

.verticalMark {
position: absolute;
top: 50%;
left: 50%;
width: 100svh;
height: 160px;
transform: translate(-50%, -50%) rotate(-90deg);
transform-origin: center center;
}
}
17 changes: 17 additions & 0 deletions src/lander/sections/CapyRailSection.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { AnimatedPanel } from '@/shared/components/AnimatedPanel'
import { AspectImage } from '@/shared/components/AspectImage'
import { StaggerWords } from '@/shared/components/StaggerWords'
Expand All @@ -15,6 +16,22 @@ const socialAssets = [
]

export function CapyRailSection() {
useEffect(() => {
const el = document.getElementById('more')
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
document.body.classList.toggle('rail-active', entry.intersectionRatio >= 0.85)
},
{ threshold: [0, 0.85, 1] },
)
observer.observe(el)
return () => {
observer.disconnect()
document.body.classList.remove('rail-active')
}
}, [])

return (
<AnimatedPanel className={`panel ${styles.capyRailPanel}`} id="more" staggerIndex={4}>
<div className={styles.verticalMarkWrap} aria-hidden="true">
Expand Down
33 changes: 33 additions & 0 deletions src/lander/sections/ContactSection.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,36 @@
margin-top: 18px;
}
}

@media (max-width: 768px) {
.contactPanel {
width: 100%;
height: 100dvh;
padding: calc(var(--space-50) + env(safe-area-inset-top)) var(--space-24)
calc(var(--space-32) + env(safe-area-inset-bottom));
backdrop-filter: none;
}

.contactPanel h2 {
font-size: clamp(36px, 9vw, 52px);
}

.contactContent {
min-height: 100%;
gap: var(--space-16);
}

.contactLead {
font-size: 22px;
margin-top: var(--space-16);
}

.emailPill {
margin-top: var(--space-16);
width: 100%;
}

.contactMeta {
margin-top: var(--space-16);
}
}
28 changes: 28 additions & 0 deletions src/lander/sections/FeaturesSection.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,31 @@
gap: var(--space-16);
}
}

@media (max-width: 768px) {
.featuresPanel {
display: flex;
flex-direction: column;
width: 100%;
height: 100dvh;
overflow-y: auto;
gap: var(--space-16);
padding: calc(var(--space-50) + env(safe-area-inset-top)) var(--space-24)
calc(var(--space-32) + env(safe-area-inset-bottom));
}

.card-col2-row1,
.card-col2-row2,
.card-col1-row2-span2 {
display: none;
}

.card-col1-row1,
.card-col2-row3 {
grid-column: unset;
grid-row: unset;
min-height: 100px;
flex: 1 1 0;
flex-shrink: 1;
}
}
49 changes: 49 additions & 0 deletions src/lander/sections/HeroSection.module.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.heroPanel {
position: relative;
width: min(1415px, calc(100vw - var(--space-24) * 2));
border: 1px solid var(--c-surface-border);
background: linear-gradient(180deg, rgba(56, 171, 162, 0.2), rgba(43, 137, 130, 0.16));
Expand All @@ -11,6 +12,10 @@
overflow: hidden;
}

.heroLogoMark {
display: none;
}

.heroRows {
display: flex;
flex-direction: column;
Expand All @@ -29,6 +34,10 @@
line-height: 0.92;
}

.heroTitleLead {
white-space: nowrap;
}

.heroRowTitle h1 span:last-child {
justify-self: end;
text-align: right;
Expand Down Expand Up @@ -144,3 +153,43 @@
line-height: 0.88;
}
}

@media (max-width: 768px) {
.heroPanel {
width: 100%;
height: 100dvh;
padding: calc(var(--space-50) + env(safe-area-inset-top)) var(--space-24)
calc(var(--space-32) + env(safe-area-inset-bottom));
justify-content: center;
backdrop-filter: none;
border: none;
background: transparent;
}

.heroLogoMark {
display: block;
position: absolute;
top: calc(var(--space-24) + env(safe-area-inset-top));
right: var(--space-24);
width: clamp(72px, 22vw, 112px);
height: auto;
pointer-events: none;
}

.heroRowTitle h1 {
font-size: clamp(42px, 13vw, 68px);
}

.heroDescription {
max-width: 100%;
}

.heroRowBottom {
flex-direction: column;
align-items: flex-start;
}

.heroCtas {
margin: var(--space-16) 0 0;
}
}
Loading
Loading