Skip to content
Merged
23 changes: 23 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,20 @@ body {
transform: translateY(-14px) scale(1.02);
}

/* Mobile: the pinned scene is replaced by a stacked list, so the tile sizes to
the screen instead of being scaled down. Height follows content โ€” the icon is
smaller here, so a fixed 560px would leave a big empty gap. */
@media (max-width: 767px) {
.app-card {
width: min(320px, 86vw);
height: auto;
min-height: 420px;
padding: 40px 24px 32px;
/* the Reveal wrapper is full-width, so centre the tile inside it */
margin-inline: auto;
}
}

@media (prefers-reduced-motion: reduce) {
.app-card {
transition: none;
Expand Down Expand Up @@ -210,6 +224,15 @@ body {
background: color-mix(in oklab, var(--acc) 18%, rgba(255, 255, 255, 0.02));
}

/* Mobile shows chips at 1:1 (no scene scaling), so nudge them up to a
comfortable reading size. */
@media (max-width: 767px) {
.cap-chip {
font-size: 12.5px;
padding: 7px 13px;
}
}

@media (prefers-reduced-motion: reduce) {
.cap-caret {
animation: none;
Expand Down
122 changes: 81 additions & 41 deletions components/sections/AppCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { useIsMobile } from "@/lib/useIsMobile";
import Reveal from "@/components/Reveal";

/** Per-card layout + scroll-entrance constants (from the source scene). */
const CFG = [
Expand Down Expand Up @@ -97,11 +99,68 @@ function AppIcon({
return <div ref={hostRef} style={{ width: size, height: size }} aria-hidden="true" />;
}

/** The card face itself โ€” identical markup in both layouts, so the mobile
* fallback keeps the exact look, just at a size that stays readable. */
function CardFace({
card,
copy,
iconSize,
}: {
card: (typeof CARDS)[number];
copy: { title: string; sub: string } | undefined;
iconSize: number;
}) {
return (
<div
className="app-card"
style={{
background: card.gradient,
// consumed by .app-card::before for the diagonal sheen
["--sheen" as string]: String(card.sheen),
}}
>
<div
style={{
fontSize: 17,
fontWeight: 500,
color: "rgba(255,255,255,0.92)",
letterSpacing: "-0.01em",
}}
>
{copy?.sub}
</div>
<div
style={{
fontSize: 48,
fontWeight: 800,
color: "#fff",
marginTop: 6,
letterSpacing: "-0.02em",
}}
>
{copy?.title}
</div>
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
}}
>
<AppIcon {...card.icon} size={iconSize} />
</div>
</div>
);
}

export default function AppCards() {
const t = useTranslations("AppCards");
const cards = t.raw("cards") as { title: string; sub: string }[];
const word1 = t("word1");
const word2 = t("word2");
const isMobile = useIsMobile();

const sceneRef = useRef<HTMLElement>(null);
const w1Ref = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -221,6 +280,27 @@ export default function AppCards() {
whiteSpace: "nowrap",
};

// โ”€โ”€ Mobile: the pinned scene scales everything to ~26%, which drops body
// copy to ~5px. Fall back to a plain stacked list at full type size.
if (isMobile) {
return (
<section id="apps" className="bg-bg px-6 py-20">
<Reveal>
<p className="label">
<span className="text-accent">//</span> {word1} {word2}
</p>
</Reveal>
<div className="mt-10 flex flex-col items-center gap-6">
{CARDS.map((card, i) => (
<Reveal key={card.key} delay={i * 0.05}>
<CardFace card={card} copy={cards[i]} iconSize={200} />
</Reveal>
))}
</div>
</section>
);
}

return (
<section
id="apps"
Expand Down Expand Up @@ -287,47 +367,7 @@ export default function AppCards() {
willChange: "transform, opacity",
}}
>
<div
className="app-card"
style={{
background: card.gradient,
// consumed by .app-card::before for the diagonal sheen
["--sheen" as string]: String(card.sheen),
}}
>
<div
style={{
fontSize: 17,
fontWeight: 500,
color: "rgba(255,255,255,0.92)",
letterSpacing: "-0.01em",
}}
>
{cards[i]?.sub}
</div>
<div
style={{
fontSize: 48,
fontWeight: 800,
color: "#fff",
marginTop: 6,
letterSpacing: "-0.02em",
}}
>
{cards[i]?.title}
</div>
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
}}
>
<AppIcon {...card.icon} size={300} />
</div>
</div>
<CardFace card={card} copy={cards[i]} iconSize={300} />
</div>
))}
</div>
Expand Down
62 changes: 60 additions & 2 deletions components/sections/CapabilitiesScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { useIsMobile } from "@/lib/useIsMobile";
import Reveal from "@/components/Reveal";

type Group = { no: string; title: string; items: string[]; tools: string[] };

Expand Down Expand Up @@ -35,6 +37,7 @@ export default function CapabilitiesScene() {
const label = t("label");
const title = t("title");
const lead = t("lead");
const isMobile = useIsMobile();

const rootRef = useRef<HTMLElement>(null);
const spotRef = useRef<HTMLDivElement>(null);
Expand All @@ -52,7 +55,7 @@ export default function CapabilitiesScene() {

useEffect(() => {
const root = rootRef.current;
if (!root) return;
if (!root || isMobile) return;

const mouse = { x: -1, y: -1 };
const spotPos = { x: 0, y: 0 };
Expand Down Expand Up @@ -329,7 +332,62 @@ export default function CapabilitiesScene() {
document.removeEventListener("visibilitychange", onVis);
window.removeEventListener("mousemove", onMouse);
};
}, [groups.length]);
}, [groups.length, isMobile]);

// โ”€โ”€ Mobile: the pinned scene scales the 1280px grid down to ~58%, dropping
// list text to 9px and chips to 7px. Fall back to a single column at full size.
if (isMobile) {
return (
<section id="capabilities" className="bg-bg px-6 py-20" ref={rootRef}>
<Reveal>
<p className="label">
<span className="text-accent">//</span> {label}
</p>
<h2 className="mt-5 text-3xl font-semibold leading-[1.15] tracking-tight">
{title}
</h2>
<p className="mt-4 text-fg-dim">{lead}</p>
</Reveal>

<div className="mt-12 space-y-10">
{groups.slice(0, 4).map((group, c) => (
<Reveal key={group.no} delay={c * 0.05}>
<div className="border-t border-line pt-8">
<div className="flex items-baseline gap-4">
<span className="font-mono text-sm" style={{ color: ACCENTS[c].hex }}>
{group.no}
</span>
<h3 className="text-xl font-medium tracking-tight">{group.title}</h3>
</div>
<ul className="mt-5 space-y-3">
{group.items.map((item) => (
<li key={item} className="flex gap-3 text-sm leading-relaxed text-fg-dim">
<span
aria-hidden
className="mt-[0.65em] h-px w-4 shrink-0"
style={{ background: `rgba(${ACCENTS[c].rgb},0.6)` }}
/>
{item}
</li>
))}
</ul>
<ul
className="mt-6 flex flex-wrap gap-2"
style={{ ["--acc" as string]: ACCENTS[c].hex }}
>
{group.tools.map((tool) => (
<li key={tool} className="cap-chip" style={{ opacity: 1 }}>
{tool}
</li>
))}
</ul>
</div>
</Reveal>
))}
</div>
</section>
);
}

return (
<section
Expand Down
26 changes: 26 additions & 0 deletions lib/useIsMobile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client";

import { useEffect, useState } from "react";

/** Tailwind's `md` breakpoint โ€” below this the pinned scroll scenes fall back
* to static stacked layouts (scaling them down makes the type unreadable). */
const MOBILE_QUERY = "(max-width: 767px)";

/**
* Tracks whether the viewport is below the `md` breakpoint.
* Returns `false` during SSR and the first paint, so the desktop markup is what
* gets pre-rendered; the mobile branch swaps in on mount.
*/
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);

useEffect(() => {
const mq = window.matchMedia(MOBILE_QUERY);
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);

return isMobile;
}
Loading